diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 40c21081..e7d54f07 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,12 +25,13 @@ permissions: jobs: tests: - runs-on: ubuntu-latest - timeout-minutes: 30 + runs-on: ${{ matrix.os }} + timeout-minutes: 60 strategy: matrix: os: - 'ubuntu-latest' + - 'macOS-latest' nim-version: - '2.2.4' - 'stable' @@ -67,8 +68,10 @@ jobs: - name: Generate test certificates run: bash tests/gen_certs.sh + # macOS runners have no Docker, so PostgreSQL is only available on Linux. - name: Start PostgreSQL id: start-psql + if: runner.os == 'Linux' run: docker compose up -d --wait background: true @@ -85,20 +88,31 @@ jobs: rm -rf ~/.nimble/pkgs2/async_postgres-* nimble install -y + # Linux runs the full suite against the live PostgreSQL above. - name: Run tests id: run-tests + if: runner.os == 'Linux' run: nimble test -y background: true + # macOS has no PostgreSQL; run only the unit and mock-server tests. + - name: Run unit tests + id: run-unit-tests + if: runner.os != 'Linux' + run: nimble test_unit -y + background: true + - name: Compile examples (asyncdispatch) run: for f in examples/*.nim; do nim c "$f"; done - name: Compile examples (chronos) run: for f in examples/*.nim; do nim c -d:asyncBackend=chronos "$f"; done + # Doc generation is platform-independent; run it once (on Linux). - name: Gen docs + if: runner.os == 'Linux' run: | nim doc --project --index:on --outdir:./htmldocs ./async_postgres.nim - - name: Wait setup - wait: [run-tests] + - name: Wait tests + wait: [run-tests, run-unit-tests] diff --git a/async_postgres.nimble b/async_postgres.nimble index fc4845e6..f6df60ac 100644 --- a/async_postgres.nimble +++ b/async_postgres.nimble @@ -13,6 +13,10 @@ requires "checksums >= 0.2.2" requires "unicodedb >= 0.13.2" requires "normalize >= 0.9.0" -task test, "test": +task test, "run the full suite (requires a live PostgreSQL on 127.0.0.1:15432)": exec "nim c -d:asyncBackend=asyncdispatch -r tests/all_tests.nim" exec "nim c -d:asyncBackend=chronos -r tests/all_tests.nim" + +task test_unit, "run unit and mock-server tests only (no PostgreSQL required)": + exec "nim c -d:asyncBackend=asyncdispatch -r tests/all_tests_unit.nim" + exec "nim c -d:asyncBackend=chronos -r tests/all_tests_unit.nim" diff --git a/audit/findings/advisory_lo_cluster.md b/audit/findings/advisory_lo_cluster.md new file mode 100644 index 00000000..5205bc4c --- /dev/null +++ b/audit/findings/advisory_lo_cluster.md @@ -0,0 +1,71 @@ +# Tier 2 監査: advisory_lock / largeobject / pool_cluster / types / errors / bytes + +--- + +- 分類: 公開境界(エラー契約) +- 重大度: Medium +- 確信度: 確定 +- 場所: async_postgres/pg_errors.nim:100-103, async_postgres/pg_pool.nim:838,856,859,1002,1003,1058,1394,1420,1457,1489 +- 事象: `PgPoolError` は全プール失敗を単一の例外型で送出する。サブタイプも enum フィールドもなく、呼び出し元が「pool closed」(永続・リトライ無意味)と「acquire timeout」(一過性・リトライ可能)を判別するには `.msg` 文字列照合が必要。 +- 根拠: + ```nim + # pg_errors.nim:100-103 + PgPoolError* = object of PgError + ## Pool-level acquire failure: acquire timeout, pool closed, waiter queue + ## full, or a failed connect attempt during acquire + ``` + 送出メッセージの種類(`rg "raise newException\(PgPoolError" async_postgres/`): + - "Pool is closed" × 5 箇所 + - "Pool acquire timeout" × 3 箇所 + - "Pool connect failed" × 1 箇所 + - "Pool connect for waiter failed" × 1 箇所 + - "Pool closed" × 2 箇所(close 内の waiter 失敗) + - "Pool cluster fallback acquire timeout" × 1 箇所(pg_pool_cluster.nim:233) + - "Failed to acquire connection for batch" × 1 箇所 + 合計 14 箇所、少なくとも 5 種の意味的に異なる失敗が同一型。 + 対照: `PgQueryError` は `sqlState` フィールドでプログラム的判別を提供。`PgTimeoutError` / `PgProtocolError` はサブタイプで分離。 +- 系統性: 単発(PgPoolError のみ。他エラー型はサブタイプまたはフィールドで判別可能) + +--- + +- 分類: セキュリティ(境界条件 / -d:danger) +- 重大度: Low +- 確信度: 中 +- 場所: async_postgres/pg_bytes.nim:53-67 +- 事象: `fromBE16` / `fromBE32` / `fromBE64` は `data[offset]` の直接インデックス参照で境界チェックを持たない。同ファイルの `writeBytesAt`(line 81-87)、`readString`(line 99-104)、`readBytes`(line 112-117)は `-d:danger` 下での `addr` による境界チェックスキップを明示的にガードしているが、`fromBE*` には同等のガードがない。`-d:danger` コンパイル時、悪意あるサーバが内部長フィールドを改竄したメッセージを送信した場合、型デコーダ経由で OOB 読み取りが発生し得る。 +- 根拠: + ```nim + # pg_bytes.nim:57-60 — ガードなし + func fromBE32*(data: openArray[byte], offset = 0): int32 {.inline.} = + int32(data[offset]) shl 24 or int32(data[offset + 1]) shl 16 or + int32(data[offset + 2]) shl 8 or int32(data[offset + 3]) + ``` + ```nim + # pg_bytes.nim:81-87 — 対照: 明示ガードあり + if src.len > 0: + if pos < 0 or src.len > dst.len - pos: + raise newException(PgProtocolError, ...) + ``` + 通常コンパイルでは Nim のインデックスチェックが機能するため、`-d:danger` 限定。`fromBE*` の呼び出しは 74 箇所(`rg -c "fromBE(16|32|64)" async_postgres/`)。プロトコルパーサ(pg_protocol.nim:1189-1209)はメッセージフレーミングを検証するが、メッセージ内部のサブフィールド長まで検証しないパスが存在する(pg_types/decoding.nim 等)。 +- 系統性: 同種パターン(ガード欠落)。`fromBE16` × 1、`fromBE32` × 1、`fromBE64` × 1 の定義に対し、ガード付きは `writeBytesAt` / `readString` / `readBytes` の 3 つ。呼び出し 74 箇所が影響範囲。 + +--- + +- 分類: 公開境界(ConnConfig の公開表面) +- 重大度: Low +- 確信度: 確定 +- 場所: async_postgres/pg_connection/types.nim:109-182 +- 事象: `ConnConfig` は `password*: string` を含む全フィールドを `*`(公開)で导出する。`sslKey*`(line 133)は PEM 秘密鍵のパス文字列であり、`password` と同等の機密性を持つが、doc コメント以外にアクセス制御やゼロ化の仕組みがない。`maxMessageSize`(line 171)のデフォルト 0 は 1 GiB(`DefaultMaxBackendMessageLen` = 1024*1024*1024, pg_protocol.nim:297)を意味し、悪意あるサーバが 1 GiB の recv バッファ確保を強制できる。 +- 根拠: + ```nim + # types.nim:171-177 + maxMessageSize*: int + ## Upper bound (in bytes) on a single backend message including + ## its 1-byte type and 4-byte length header. A server claiming a + ## larger message is rejected with `PgProtocolError` before any + ## further recv-buffer growth, capping memory exposure to a + ## misbehaving or malicious peer. ``0`` (default) selects + ## `DefaultMaxBackendMessageLen` (1 GiB). + ``` + 1 GiB は DoS 耐性として大きい。libpq のデフォルトは無制限ではない(`PQsetSingleRowMode` 等は別機構)。ただし doc で明示されており、設定で下げられるため重大度は Low。 +- 系統性: 単発 diff --git a/audit/findings/async_backend.md b/audit/findings/async_backend.md new file mode 100644 index 00000000..39187ba0 --- /dev/null +++ b/audit/findings/async_backend.md @@ -0,0 +1,98 @@ +# async_backend.nim 監査所見 + +対象: `async_postgres/async_backend.nim` (354行)。asyncdispatch / chronos 抽象化層。fan-in 26(全模块2位)。 +背景: デフォルトバックエンドは asyncdispatch (`async_backend.nim:9`)。CI は両バックエンドで走る +(`map_tests_conventions.md`: `nimble test` が `-d:asyncBackend=asyncdispatch` と `chronos` の両方実行)。 +chronos 4.4.0 を実機で確認済み(`~/.nimble/pkgs2/chronos-4.4.0-...`)。 + +--- + +- 分類: テスト(構造的空白) +- 重大度: Medium +- 確信度: 確定 +- 場所: tests/test_async_backend.nim:1-36(専用テスト全体)、対象モジュール async_postgres/async_backend.nim +- 事象: + fan-in 26(全模块2位、`map.md:21`)の基盤抽象に対し、専用テストは 36 行で `makeAsyncSeqByteCallback` の回帰(final-expression / early return / raise の3ケース)のみ。この抽象が上位に約束する中核 proc — `wait`(両 backend のタイムアウト/orphan 意味、async_backend.nim:25-35 / 162-191)、`asyncSpawn` (:206-215)、`allFutures` (:222-236)、`cancelAndWait` (:193-204)、`sleepAsync`/`sleepMsAsync` (:37-39 / 238-244)、`cancelTimer` (:41-44 / 246-250)、`registerFdReader`/`unregisterFdReader` (:46-68 / 252-271)、`scheduleSoon` (:70-76 / 273-278)、`completed` (:217-220)、`Duration`/`Moment` 演算 (:89-160)、`remainingDeadlineDuration` (:335-354)、`declareAsyncCallback` マクロ (:283-301)、`makeAsyncSinkByteCallback` テンプレート (:303-319) — に専用ユニットテストは無い。全て e2e/mock テストの間接行使のみ。 +- 根拠: コード引用と推論 + - test_async_backend.nim:10-36 は `declareAsyncCallback(TestCb, ...)` + `makeAsyncSeqByteCallback` のみ。`wait`/`asyncSpawn`/`allFutures` 等の名前を直接検証するテストはファイル内に存在しない(`expect ValueError` 1件のみ、`map_tests_conventions.md:152`)。 + - CI は両バックエンドで走るため、コンパイル破壊や大まかな動作乖離は間接テストで捕捉される。しかし**意味的に微妙な乖離**は happy path 間接テストでは検出不能: 例) asyncdispatch `wait` の orphan 登録タイミング、`asyncSpawn` が投げる Defect の種別(chronos `FutureDefect` vs asyncdispatch `AssertionDefect`、下記「検証済み」参照)、`toMilliseconds` の sub-ms 切り捨て(所見4)、`allFutures` の完了集約。これらは backend 非対称バグの温床であり、fan-in 26 のブラスト半径に対して専用テスト36行は不均衡(`map.md:53` が構造リスクとして指名済み)。 +- 系統性: 単発(このモジュール固有の構造問題)。ただし「薄い/無い」モジュール群(pg_errors 専用なし、type_lookup、cache、pg_bearssl の asyncdispatch レグ未実行)の一つであり、基盤層ほど専用テストが薄い傾向の一部。`map_tests_conventions.md:120-130`。 + +--- + +- 分類: 設計整合性(抽象の漏れ出し)/ 公開境界 +- 重大度: Low +- 確信度: 確定 +- 場所: async_postgres/async_backend.nim:21-35(chronos 分岐の `wait`)、asyncdispatch 分岐 162-191 +- 事象: + chronos 分岐では `export chronos` (:23) により chronos 本体の `wait` が無修飾で再公開される。このため `fut.wait(dur)`(2引数)は**モジュール独自の `wait` ラッパ(:25-35)を経由せず chronos ネイティブ `wait` に解決する**。独自ラッパの3引数版は chronos 分岐では `onOrphan` に既定値が無く(:26、asyncdispatch 版 :163 は `= nil`)、`onOrphan` を明示した3引数呼び出しでのみ到達可能で、到達しても `let _ = onOrphan` (:34) で無視する。つまり chronos では、最も一般的な2引数 `wait` 呼び出しの意味論をこのモジュールは実際には仲介しておらず、独自 `wait` の docstring(:28-33 の orphan 契約説明)は chronos の2引数呼び出しには適用されない。このオーバーロード解決の構造(2引数=chronosネイティブ / 3引数=ローカルラッパ)はどこにも文書化されていない。 +- 根拠: コード引用と推論 + - chronos 4.4.0 `internal/asyncfutures.nim:1532`: `proc wait*[T](fut: Future[T], timeout = InfiniteDuration): Future[T]`(1〜2引数)。これが `export chronos` で可见。 + - ローカル chronos 版 `wait`(async_backend.nim:25-27)は `onOrphan` 必須(既定なし)。よって `fut.wait(dur)` は chronos ネイティブに、`fut.wait(dur, onOrphan=x)` のみローカルに解決(3引数版は chronos ネイティブに無いため曖昧性なし)。 + - asyncdispatch 版(:162-163)は `onOrphan = nil` 既定のため2引数・3引数ともローカルに解決。両 backend で「`fut.wait(dur)` がどの proc を呼ぶか」が異なる(chronos: ネイティブ / asyncdispatch: ローカル、onOrphan=nil で orphan 未処理)。 + - 結果として、保守者が chronos 版ローカル `wait` に横断的処理(tracing・deadline 記録等)を追加しても、それは3引数呼び出しにしか適用されず、圧倒的に多い2引数呼び出しを黙って取りこぼす。抽象が backend 固有の解決を上位に漏らしている。動作自体は正しい(chronos ネイティブはタイムアウトでキャンセルするため orphan 処理不要)だが、この解決構造は未文書化。 +- 系統性: 単発。`export chronos` による意図しない再公開オーバーロードは `wait` のみで確認(`sleepAsync` も chronos ネイティブが再公開されるが、asyncdispatch 版ローカル `sleepAsync(d: Duration)` :238 とシグネチャ一致・意味等価で漏れ出しは軽微)。grep パターン: `rg -n "^ export chronos" async_postgres/async_backend.nim` → 1件(:23)。 + +--- + +- 分類: 正当性 / 公開境界(未文書の意味非対称) +- 重大度: Low +- 確信度: 高 +- 場所: async_postgres/async_backend.nim:136-138 (`toMilliseconds`)、使用箇所 179 (`wait`)・240 (`sleepAsync`) +- 事象: + asyncdispatch 分岐の `toMilliseconds(d) = int(int64(d) div 1_000_000)` (:136-138) は sub-ms を 0 へ切り捨てる。このため `wait(fut, sub-ms)` は `withTimeout(fut, 0)` となり、保留中の future に対して**即タイムアウト**(`AsyncTimeoutError`)する。一方 chronos の `wait(fut, sub-ms)` はその時間を待つ。`sleepAsync(sub-ms)` も同様に asyncdispatch では 0ms(即 yield)だが chronos は ns 精度で待つ。この切り捨て・即タイムアウト化は `wait`/`sleepAsync`/`toMilliseconds` のいずれの doc にも記載がない(`toMilliseconds` doc :137 は「Convert Duration to milliseconds」のみで切り捨ての注意なし)。 +- 根拠: コード引用と推論 + - asyncdispatch `withTimeout`(Nim 2.2.10 `lib/pure/asyncdispatch.nim:1932-1957`)は `timeoutFuture = sleepAsync(timeout)` で、`sleepAsync(0)` (:1920-1930) は deadline=now のタイマを積み次の poll で発火。pending な `fut` に対し timeout 側が勝ち `false` → ローカル `wait` は `AsyncTimeoutError` を raise(async_backend.nim:181-187)。 + - chronos は `Duration` が ns 精度で `wait`/`sleepAsync` が sub-ms を honoring。よって `wait(fut, nanoseconds(500_000))` は asyncdispatch=即タイムアウト / chronos=0.5ms 待機、の意味乖離。 + - 緩和: deadline 由来の値は `remainingDeadlineDuration` が 1ms 床で保護(:335-352、doc :345-349 が「deadlines smaller than a few milliseconds are not meaningfully enforced」と言及)。しかしこれは `remainingDeadlineDuration` 固有の説明で、`wait`/`sleepAsync` への直接の sub-ms 渡しは保護も文書化もされない。 + - 潜在性: 現在本番で sub-ms を `wait`/`sleepAsync` へ直接渡す呼び出しは未確認(観測最小は `milliseconds(1)`、例 test_e2e_listen.nim:469)。実害は潜在。 +- 系統性: 同種パターン(小規模・2箇所)。`toMilliseconds` 使用は `wait`:179 と `sleepAsync`:240 の2箇所(grep パターン: `rg -n "toMilliseconds" async_postgres/async_backend.nim` → 定義1 + 使用2)。両者とも sub-ms 切り捨て→0 の同一挙動。 + +--- + +## 検証済み・所見なし(系統比較の結果) + +- **`asyncSpawn` の Defect 意味は chronos と実質一致**。chronos 4.4.0 `internal/asyncfutures.nim:665-689`: future 失敗時に `FutureDefect` を raise、キャンセル時も `FutureDefect`、既に finished なら即 `cb(nil)` 処理。asyncdispatch 版(async_backend.nim:206-215)は失敗時に `raiseAssert`(`AssertionDefect`)を raise。Defect の**種別**は異なる(`FutureDefect` vs `AssertionDefect`)が、doc (:207-208) は「a Defect is raised (matching chronos behaviour)」と種別を限定せず述べるのみで、不正確ではない。asyncdispatch にはキャンセルが無いため cancelled 分岐の不在は本質的。`addCallback` は asyncdispatch でも finished future に対し同期発火するため「既に finished なら即処理」も対称。所見なし。 +- **`allFutures`(asyncdispatch 版 :222-236)の完了集約は正しい**。`var remaining` をクロージャで共有、各 future の `addCallback` が `dec remaining` し 0 で `retFuture.complete()`。単一スレッドイベントループのためデータ競合なし、`complete` はちょうど1回(remaining は futures.len から始まり各 future 1回ずつ dec)。空 seq は :225-227 で即 complete(chronos `allFutures` と一致)。失敗 future のエラーは伝播しないが、これは chronos `allFutures`(asyncfutures.nim:1000)も同様(個別確認は呼び出し側責任)。所見なし。 +- **`Duration`/`Moment` 演算(:89-160)は off-by-one・borrow 漏れなし**。`==`/`<`/`<=`/`-`/`+` は `{.borrow.}`、`Moment` の `-`/`+`/`<=`/`<` は ticks 基準で明示実装。`hours`/`minutes` の int64 乗算は非現実的な巨大値(約292年相当)でのみ overflow し実害なし。`$`(:125-134) は ns/ms/s のみ整形(minutes/microseconds 未整形)だが表示専用で意味影響なし。所見なし。 +- **`registerFdReader`/`unregisterFdReader`/`scheduleSoon`/`cancelTimer` の backend 対称性**。両分岐とも register を try 外で実行し addReader/addRead 失敗時に unregister して再 raise(chronos :50-62 / asyncdispatch :255-267)で構造対称。`cancelTimer` は chronos `cancelSoon`(:41-44) vs asyncdispatch no-op(:246-250) だが doc (:247-249) が「timers cannot be cancelled, but they complete harmlessly」と明記済み=文書化された非対称。所見なし。 +- **`close()` の listen pump 処理は backend 分岐済みで正しい**(lifecycle.nim:450-461)。asyncdispatch で `listenStopRequested=true` → `closeTransport()` → `await pump`、chronos で `cancelAndWait`。`test_e2e_listen.nim:296-324` が復活非再発を検証。同じ保護は `abortListenTask`(notify.nim:288-)にも適用済み。 +- **`wait` の orphan 処理を要する呼び出し側は backend を正しく分岐**。`lifecycle.nim:535-552`(`attemptHostTimed`)と `pg_pool.nim:965-985`(acquire)は `when hasAsyncDispatch:` で `onOrphan` 付き `wait` を使い orphan 接続を close、chronos は2引数 `wait`(キャンセルされる)。`pg_pool_cluster.nim:194-236` はタイムアウト時に `asyncSpawn drainAbandonedAcquire(...)` で放棄 acquire を回収(doc :158-168 が非対称を明記)。`notify.nim:419` の `notifyWaiter.wait(timeout)` は orphan 化する future が純粋な同期 future(socket/IO を保持しない)ため `finally: conn.notifyWaiter = nil`(:425) で参照が切れ無害。`simple_query.nim:246/259`(`awaitOrInvalidate`/`awaitVoidOrInvalidate`)は orphan を onOrphan ではなく `invalidateOnTimeout`(csClosed 化、:224-226)で毒化して処理=`buffer_io.md` 所見1 と同一の文書化済みパターン。新規の誤Handlingは無し。 + +--- + +## 2026-08-13 追記(S6 テスト拡充の実施と新規観測) + +### 対処済み(S6: async_backend 専用テストの拡充) + +- **テスト空白の解消**: tests/test_async_backend.nim を 36 行 → 約250 行へ拡充。 + - 追加: makeAsyncSinkByteCallback / Duration 変換・比較・演算・`$` / Moment 演算 / wait 成功・void・ + timeout・onOrphan(asyncdispatch: 発火・非発火)・chronos キャンセル / cancelAndWait / asyncSpawn / + allFutures(空・失敗混在)/ completed / cancelTimer / scheduleSoon / registerFdReader(posix pipe)/ + remainingDeadlineDuration(過去・未来)。 + - 両バックエンドで全テスト成功(asyncdispatch 24件 / chronos 21件、`nim c -r` 確認済み)。 +- **テスト作成時に発見したコンパイラ癖(プロジェクト起因ではない)**: Nim 2.2.4 で、unittest の + `test` テンプレート展開スコープ内では 1) var 宣言より後ろに置いた無名 async proc リテラル + (sink パラメータの callback 生成)が nimcall のまま残る 2) `import std/posix` の `wait` シンボルが + chronos asyncmacro の `result` 解決と衝突する。テスト側で「proc をブロック先頭に置く」 + 「from std/posix import」で回避。既存テストがこの制約を踏まえていたため(proc t() を先頭に置く + 慣習、test_e2e_copy.nim:201 等)本番コードには影響しない。 + +### 新規所見(S3 クラスタの残存、Low) + +- **3引数 `wait` ラッパの chronos コンパイル不能(void future)**: async_backend.nim:25-35 の chronos + 分岐ラッパは `return await chronos.wait(fut, timeout)` で T=void のとき chronos 4.4.0 asyncmacro の + `result` 曖昧化(asyncmacro.nim:365)で**コンパイルエラー**になる。本番は2引数形式(chronos ネイティブ + wait に解決)のみを使用するため潜伏(本タスクのテストで実証)。doc は「onOrphan は chronos では呼ばれ + ない」と述べるが、ラッパ自体が void で使えない事実は未文書化。asyncdispatch 分岐は void 対応 + (:188-189 `when T is void: fut.read()`)。 +- **Moment 減算の負値クランプ非対称**: chronos `Moment - Moment` は負値を 0 にクランプ + (timer.nim:176-181 "Duration can't be negative")、asyncdispatch のシムは符号付き。本番の使用箇所 + (`remainingDeadlineDuration` は `deadline <= now` ガード後にのみ減算)は全て正値側のみのため実害なし。 + テストで両 backend の挙動差を文書化(Moment スイートの when 分岐)。 +- **クライアント証明書と鍵の不一致検出の非対称**: asyncdispatch は `SSL_CTX_check_private_key` + (ssl.nim:500-502、OpenSSL 3.6 では use_PrivateKey_file 段階で "key values mismatch" として検出)で + fail-closed。chronos/BearSSL はクライアント側でペアリング検証が無く、サーバがクライアント認証を + 要求しない限り不一致ペアでも接続が成功する。実害は限定的(PG 既定はクライアント認証を要求しないため + 不一致が顕在化しない)だが、asyncdispatch の失敗→chronos の成功という silent な backend 差。 + test_tls_error_paths.nim に asyncdispatch 側の回帰テストのみ追加(chronos 側は挙動として正)。 diff --git a/audit/findings/auth.md b/audit/findings/auth.md new file mode 100644 index 00000000..a0187546 --- /dev/null +++ b/audit/findings/auth.md @@ -0,0 +1,75 @@ +# auth.md — pg_auth.nim / pg_saslprep.nim 監査所見 + +## 所見 1 + +- 分類: セキュリティ / 正当性(SASLprep 正規化の RFC 3454 テーブル誤り) +- 重大度: Medium +- 確信度: 確定 +- 場所: async_postgres/pg_saslprep.nim:37, async_postgres/pg_saslprep.nim:26, async_postgres/pg_saslprep.nim:115-118 +- 事象: + RFC 3454 のテーブル帰属が誤っており、3 つのコードポイントの正規化結果が PostgreSQL の pg_saslprep と一致しない。 + + (a) U+200B (ZERO WIDTH SPACE) — RFC 3454 B.1(map to nothing)にのみ属し、C.1.2(non-ASCII space)には属さない。しかし `isNonAsciiSpace`(37行)に 0x200B が含まれている。マッピング段階(115-118行)で `isNonAsciiSpace` が `isMapToNothing` より先に検査されるため、U+200B は削除されず U+0020 に変換される。 + + (b) U+200C (ZERO WIDTH NON-JOINER) / U+200D (ZERO WIDTH JOINER) — RFC 3454 C.2.2(non-ASCII control, prohibited)にのみ属し、B.1 には属さない。しかし `isMapToNothing`(26行)に含まれている。マッピングが禁止検査より先に走るため、本来 prohibited として raw fallback になるべき文字が黙って削除される。 + +- 根拠: + + RFC 3454 Appendix B.1 の該当箇所: + > 00AD; SOFT HYPHEN / 034F; COMBINING GRAPHEME JOINER / 1806 / 180B / 180C / 180D / **200B; ZERO WIDTH SPACE** / 2060; WORD JOINER / FE00..FE0F / FEFF + + RFC 3454 Appendix C.1.2 の該当箇所(Zs カテゴリ、U+0020 以外): + > 00A0 / 1680 / 2000..200A / 202F / 205F / 3000 + (U+200B は Zs ではなく Cf カテゴリであり C.1.2 に含まれない) + + RFC 3454 Appendix C.2.2 の該当箇所: + > ... / **200C; ZERO WIDTH NON-JOINER** / **200D; ZERO WIDTH JOINER** / ... + (U+200C, U+200D は B.1 に含まれない) + + コード(pg_saslprep.nim:115-118): + ```nim + if isNonAsciiSpace(cp): # 0x200B がここで space に変換される(誤り) + mapped.add(char(0x20)) + elif isMapToNothing(cp): # 0x200C, 0x200D がここで削除される(誤り) + discard + ``` + + 影響: + - U+200B 含有パスワード: クライアントは "a b"(space 挿入)で SCRAM 証明を計算、PostgreSQL は "ab"(削除)で検証 → 認証失敗。 + - U+200C/U+200D 含有パスワード: クライアントは削除して正規化を続行、PostgreSQL は prohibited として raw fallback → 正規化パスが分岐し認証失敗。 + - テスト(test_saslprep.nim:25-34)が誤ったテーブル帰属を前提に記述されており、バグを固定化している。テストコメント "0x200C is in B.1" および "0x200B is in both C.1.2 and B.1" は RFC 3454 と矛盾する。 + +- 系統性: 同種パターン(3 code points / 2 lookup functions) + grep パターン: `rg -n "0x200B|0x200C|0x200D" async_postgres/pg_saslprep.nim` → 3 箇所(26行, 37行, 52行)。 + 52行の `isProhibited` 内 0x200C/0x200D は正しい(C.2.2)が、26行の `isMapToNothing` が先にマッチするため死にコードとなっている。 + +--- + +## 所見 2 + +- 分類: セキュリティ(SCRAM server-first-message の mandatory extension 未検査) +- 重大度: Low +- 確信度: 確定 +- 場所: async_postgres/pg_auth.nim:126-138 +- 事象: + `scramClientFinalMessage` の server-first-message パーサは `r=`, `s=`, `i=` のみを認識し、RFC 5802 §5.1 が MUST で要求する `m=`(mandatory extension)属性の検出・中断を行わない。`m=` を含むメッセージを受信した場合、属性は黙って無視され SCRAM 交換が継続する。 +- 根拠: + + RFC 5802 Section 5.1: + > "If the client receives a server-first-message containing an 'm' attribute, it MUST abort the authentication." + + コード(pg_auth.nim:126-138): + ```nim + for part in serverFirstMsg.split(','): + if part.startsWith("r="): + ... + elif part.startsWith("s="): + ... + elif part.startsWith("i="): + ... + ``` + `m=` に対する分岐が存在しない。未知属性はすべて無視される。 + + 現実の PostgreSQL サーバは `m=` を送信しないため実害は限定的。server signature 検証(scramVerifyServerFinal)が別途存在するため認証バイパスには至らない。RFC 適合性の問題。 + +- 系統性: 単発(`m=` 検査の欠落は1箇所のみ。`rg -n '"m="' async_postgres/` → 0 件) diff --git a/audit/findings/buffer_io.md b/audit/findings/buffer_io.md new file mode 100644 index 00000000..f4f031a3 --- /dev/null +++ b/audit/findings/buffer_io.md @@ -0,0 +1,16 @@ +# buffer_io.nim 監査所見 + +対象: `async_postgres/pg_connection/buffer_io.nim` (780行) +バックグラウンド: デフォルトバックエンドは asyncdispatch (`async_backend.nim:9`)。 +テストは chronos で実行 (`async-postgres.nimble`: `-d:asyncBackend=chronos`)。 + +所見はすべて対処済み(対応は git log を参照)。以下は検証済み・所見なしとした系統比較の結果。 + +--- + +## 検証済み・所見なし(系統比較の結果) + +- `pumpUntilReady` 3オーバーロード (376-415 / 417-448 / 450-482) のエラー/キャンセル処理は**完全に対称**。`bmkErrorResponse` 捕捉、`bmkReadyForQuery` での `conn.txStatus` 更新・`if conn.state != csClosed: conn.state = csReady`・`readyBody`・`if queryError != nil: raise queryError`・`break pumpLoop` は3者で文字列レベルで同一。非対称は無い(`nextMessage` へ渡す引数のみ用途通り異なる: overload1=`(resultData, rowCountPtr)`、overload2=`(resultData, nil, onRow, onRowErr)`、overload3=`(skipDataRow=true)`)。 +- `parseBackendMessage` の `maxLen` 上限 (pg_protocol.nim:1201 `int64(msgLen) >= int64(maxLen)`) は off-by-one なし: 総サイズ `maxLen`(msgLen=maxLen-1)は許可、`maxLen+1`(msgLen=maxLen)は拒否。巨大メッセージによる過剰確保は `effectiveMaxMessageSize`(既定1GiB, types.nim:771-777)で上界付き。 +- `closeTransport` (585-621) は nil チェック + nil 代入により冪等。`isConnected` (709-729) は close 後(asyncdispatch: `socket=nil`、chronos: `writer=nil`)に false を返す。 +- `nextMessage` の `recvBufStart` 更新 (323-324行) は `onRow` 例外捕捉 (332-333) より前に行われ、`PgProtocolError` 時は `csClosed` 化 (319行) して再 raise。`pos` は `consumed >= 5` で単調増加するため無限ループなし。 diff --git a/audit/findings/decoding.md b/audit/findings/decoding.md new file mode 100644 index 00000000..a6b2bb98 --- /dev/null +++ b/audit/findings/decoding.md @@ -0,0 +1,42 @@ +# Audit Findings: async_postgres/pg_types/decoding.nim + +対象: `async_postgres/pg_types/decoding.nim` (935行) — サーバ由来データの遅延デコード(Row アクセサから呼ばれる信頼境界#2)。 +脅威モデル: サーバ(または MITM)が malformed なテキスト/バイナリ値を返す。 +方法: 全 26 公開 proc を境界条件/エラーパス/セキュリティ/正当性/公開境界の観点で精査。疑わしい点は +Nim の実セマンティクスを `/tmp` で実測して確定。既存レビュー `reviews/review_decoding.md` を突合。 + +総括(系統カウント): 報告対象 **0件**(旧 F1 は対処済み、下記参照)。 + +バイナリデコーダ群(hstore/numeric/array/composite/tsvector/tsquery/inet/point/time/timestamp/date)の +境界・over-read・巨大確保防護は一貫して硬化済(count を `(data.len - X) div Y` で事前抑止 + 要素ごとの +pos 検査)。over-read / int overflow / 巨大確保の未防護箇所は発見せず。 + +## 対処済み(削除) + +- **旧 F1 `decodeNumericBinary` の base-10000 digit 値域未検証**(decoding.nim:89-91): + 各 digit が `[0, 9999]` の範囲外なら `PgTypeError` を送出するよう修正。 + encoding.nim:681 の「Mirror decodeNumericBinary」コメントが正確になる。test_types.nim に負テスト 2 件追加 + (digit=10000 と digit=-1 の拒否)。 + +--- + +## 調査したが所見としなかった項目(透明性のため記録) + +- **既存レビューの誤り(コード缺陷ではない)**: `reviews/review_decoding.md:56-61` は + `decodeBinaryTimestamp` の `if fracUs < 0` 分岐(decoding.nim:122-124)を「到達不能なデッドコード」と主張するが、 + 誤り。Nim の `mod` は truncating div に従い被除数の符号を引き継ぐため、1970 年以前(unixUs < 0)で + `unixUs mod 1_000_000` は負になる。実測: unixUs=-500000 → fracUs=-500000(分岐到達)、正規化後 + unixSec=-1, fracUs=500000 → `1969-12-31 23:59:59.500000`(正确)。コードは正しく、削除すると regression になる。 + レビューの「Nim の div は床除算」という前提自体が誤り(Bug 1 項の own 訂正と矛盾)。 +- **parseTimeTzText のオフセット成分未検証**(decoding.nim:454-466): offM/offS の 0..59 検査が無く "+00:99" + を受容する。ただしバイナリ経路 `decodeBinaryTimeTz`(174-188)も int32.low 以外は無制限に受容するため + 経路間非対称ではなく、PgTimeTz.utcOffset が int32 秒を保持する設計とも整合。単発 Low のため所見から除外。 +- **parseIntervalText の時刻部重複**(decoding.nim:632-637): 時刻部が 2 回現れると後者が前者を上書きする。 + PostgreSQL の default intervalstyle は時刻部を 1 回しか出力せず、self-delimiting なパーサの設計上 + 想定の範囲外。単発 Low のため除外。 +- **decodeBinaryTsQuery の ntokens 不使用**(decoding.nim:854-860): ntokens は負/零のみ検査し実 parse は + トークン型駆動で ntokens を強制しない。ただし再帰パーサは data.len と depth(1000) で完全に境界され + over-read/クラッシュは無し。self-delimiting 設計として許容範囲と判断。 +- **parseTimestampText/parseDateText の例外契約**: `raises: [CatchableError]` 宣言で `except TimeParseError, IndexDefect` + のみ捕捉。`times.parse` が他に ValueError 等を漏らす可能性を実測で探ったが、空文字/不正月日/巨大年 + いずれも TimeParseError のみで、漏洩を実証できず。**未調査**(実証不足のため所見とせず)。 diff --git a/audit/findings/dsn.md b/audit/findings/dsn.md new file mode 100644 index 00000000..f97ff919 --- /dev/null +++ b/audit/findings/dsn.md @@ -0,0 +1,89 @@ +# Audit Findings: async_postgres/pg_connection/dsn.nim + +対象: `async_postgres/pg_connection/dsn.nim` (719行) — DSN 解析(URI + libpq keyword=value)、`initConnConfig`、 +`parseDsn`、sslcert/sslkey/sslrootcert のファイル読込(`readPemFileParam`:214)と sslkey 権限ビット検査。 +信頼境界#3(利用者入力)。ファイルシステム読込を伴う唯一の接続設定経路。 + +焦点: 悪意ある DSN による (a) プロセスクラッシュ、(b) パース層の検証空隙、(c) ファイル読込/権限検査の +TOCTOU・symlink 回避、(d) percent-encoding / host 注入、(e) URI と keyword=value の解釈不一致。 + +総括(系統カウント): +- **Low 1件**(確定・実証、系統): パース層が NUL バイト / 空キーの検証を encode 層まで遅延しており、 + URI と keyword=value で非対称(所見1)。注入自体は encode 層で fail-closed(exploit 不可)。 +- 既存レビュー `reviews/review_dsn.md` の「バグなし」判定は、ファイル読込・percent-decode・host 注入の + 各核心については本調査でも追認する(下記「追認済み」)。 +- 対処済み(削除): `connect_timeout` の `seconds()` 変換で int64 overflow が `OverflowDefect` として + 漏出していた(元・所見1、Medium)→ `const maxTimeoutSecs = high(int64) div 1_000_000_000` による + 上限検査で catchable な `PgError` に変換済み。 +- RELEASE_TODO T3「空 PEM ファイル拒否ガード (242-243) 未テスト」は、本調査で **ガードの動作を実証確認** + (`parseDsn("...?sslrootcert=")` → `PgError: sslrootcert file is empty`)。コード欠陥ではなく + テスト欠落のみ。単発Low(テスト空白)のため所見としては起票しない。 + +追認済み(所見にしない): +- `openRegularFile`(186-212): `open(O_NONBLOCK)` → `fstat(fd)` → `S_ISREG` → `O_NONBLOCK` 解除 → `open(File, fd)` + の順。型検査・権限検査・読込が全て同一 fd(同一 inode)上で完結し、TOCTOU 窓は閉じている。symlink は + `open` が原子解決し `fstat` は解決済みターゲットの mode を返すため、権限検査(225)はターゲットに対して + 機能する(回避なし)。`S_IRWXG or S_IRWXO`(225) は group/other の全 rwx ビットを拒否(setuid/setgid は + アクセス権を付与しないため対象外で正しい)。 +- `pctDecode`(506-523): 境界チェック `i + 2 >= s.len`(514) は `s="%41"`(len3) を受理・`s="%4"`(len2)/`s="%"`(len1) + を拒否する正しい式。ゼロバイト拒否(517-518)・`+` の非変換いずれも libpq 互換(実証: テスト 134-142, 127-132)。 +- `parseUriDsn` の `rfind('@')`(555) はパスワード内 `@` を保護、IPv6 は括弧必須で無括弧 `::1` を拒否(613-616)、 + authority のカンマ分割は percent-decode 前・query は decode 後(583-587, 623-638) — いずれも実証済(テスト + 104-120, 582-590)。`?` の query 分割は生の先頭 `?` のみ(`%3F` は database 名に残り query 注入不可)。 + +--- + +- 分類: セキュリティ / 正当性(パース層の検証空隙・DSN 形式間の非対称) +- 重大度: Low +- 確信度: 確定 +- 場所: async_postgres/pg_connection/dsn.nim:429-432,449-478(keyword=value の key/value 読込、NUL 無検査)/623-629(URI query、空キー無検査)/対照 517-518(URI pctDecode の NUL 拒否)・433-434(keyword=value の空キー拒否)/防衛 async_postgres/pg_protocol.nim:439-441,494-497 +- 事象: + パース層が、ワイヤで意味を持つ NUL バイトと空キーの検証を、形式によって片側しか行っていない。 + (a) **NUL**: URI 形式は `pctDecode`(517-518) がパース時にゼロバイトを `PgError` で拒否するが、 + keyword=value 形式は key(430-432)・quoted value(453-464)・unquoted value(470-478) のいずれも NUL を + 停止文字集合に含まず、そのまま受理する。`parseDsn("host=h evilkey\x00inject=x")` は成功し + `extraParams=@[("evilkey\x00inject", "x")]` となる(実証)。 + (b) **空キー**: keyword=value 形式は `=value` を「Empty key」でパース時に拒否(433-434)するが、URI の query + 経路(625-629)は `pair.find('=')` が 0 を返す `=value` をスキップせず、`pctDecode("")=""` の空キーを + そのまま `applyParam` → `extraParams.add`(401) へ通す。`parseDsn("postgresql://host/db?=value")` は成功し + `extraParams=@[("", "value")]` となる(実証)。 + いずれも StartupMessage へは **到達しない**: `encodeStartup` が空キーを `ValueError` で拒否 + (pg_protocol.nim:494-497)、`addCString` が埋め込み NUL を `ValueError` で拒否(439-441)。実証: + `encodeStartup("u","d",@[("evil\x00key","v")])` → `ValueError: addCString: embedded NUL byte`、 + `encodeStartup("u","d",@[("","v")])` → `ValueError: encodeStartup: empty key`。 + 従って startup-parameter 注入としては **exploit 不可(fail-closed)**。残る影響は (1) `parseDsn` が成功して + 不正な `ConnConfig` を返し、拒否が `connect()` 時まで遅延する、(2) その拒否が `PgError` ではなく `ValueError` + で、DSN 形式によってエラー型・タイミングが非対称、の2点。user/password/database/applicationName/ + extraParams の全文字列フィールドが `addCString` を経由するため(lifecycle.nim:289 → encodeStartup:485-499、 + password は encodePassword:531 → addCString)、NUL がワイヤに抜ける経路は無い。 +- 根拠: コード引用と推論 + ```nim + # keyword=value: key は NUL を停止文字に含めず読み、空キーのみ拒否 + while i < dsn.len and dsn[i] notin {'=', ' ', '\t', '\n', '\r'}: # 430: \0 は停止集合に無い + key.add dsn[i]; inc i + if key.len == 0: # 433: 空キーはここで拒否 + raise newException(PgError, "Empty key in connection string") + ``` + ```nim + # URI query: 空キー・NUL(decode 後)の検査が無い + for pair in queryStr.split('&'): + let epos = pair.find('=') + if epos < 0: # 626: '=' 無しのみスキップ、"=value"(epos=0) は通す + continue + let key = pctDecode(pair[0 ..< epos]) # 628: "" や NUL 入りになり得る(NUL は pctDecode が拒否) + let val = pctDecode(pair[epos + 1 .. ^1]) + ... + else: + result.applyParam(key, val) # 638 → extraParams.add(401) + ``` + 非対称の整理: NUL は「URI=パース時拒否 / keyword=value=encode 時拒否」、空キーは「keyword=value=パース時拒否 / + URI=encode 時拒否」。両形式とも最終的に encode 層が守るため注入にはならないが、パース層のガードが + 互いに逆の穴を持つ。`pctDecode` の NUL 拒否(517-518) と `encodeStartup` の空キー拒否(494-497) という + 「片側の形式にだけ存在するパース時ガード」が、もう片側の形式には対応するガードを欠く構造。 +- 系統性: 同種パターン(パース層で取りこぼし encode 層で捕捉、の非対称)。 + NUL 無検査の読込箇所 **3箇所**(key 430-432 / quoted value 453-464 / unquoted value 470-478、全て + `parseKeyValueDsn` 内、grep `notin {'=', ' ', '\\t', '\\n', '\\r'}` = 430,470 の2行+quoted ループ 453)。 + 空キー無検査 **1箇所**(URI query 625-629)。対照ガード: pctDecode NUL 拒否 **1箇所**(517-518)、 + keyword=value 空キー拒否 **1箇所**(433-434)。encode 層ガード: `addCString` NUL **1箇所**(pg_protocol.nim:441)、 + `encodeStartup` 空キー **1箇所**(496)。`extraParams.add` は applyParam 集約の **1箇所**(401)で、 + URI/keyword=value 両形式の未知キーが全てここに合流する。 diff --git a/audit/findings/encoding.md b/audit/findings/encoding.md new file mode 100644 index 00000000..18779bbd --- /dev/null +++ b/audit/findings/encoding.md @@ -0,0 +1,92 @@ +# Audit Findings: async_postgres/pg_types/encoding.nim + +対象: `async_postgres/pg_types/encoding.nim` (1940行、最大の型ファイル) — 利用者値 → PostgreSQL パラメータへの encode(テキスト/バイナリ)。`toPgParam` / `toPgBinaryParam` / `toPgParamInline`。 +焦点: int32 長ラップガードの適用漏れを全 `toPgBinaryParam` で横断集計、テキスト/バイナリ格式・UTC・scale の一致、`newSeq[byte](size)` の巨大確保。 + +総括(系統カウント): +- 内部 int32 長/カウントプレフィックスを `writeBE32` で書く箇所は **10箇所**(grep `writeBE32\((pos|[0-9]+), int32\(`)。 + 加えて `data.writeBE32(0, v.nbits)`(882、int32 キャスト無し)が1箇所。**11箇所全てにガードあり**(詳細は所見1)。 + → 「int32 長ラップガードの適用漏れ」は encoder には残っていない(検証済)。 +- 既存レビュー `reviews/review_encoding.md` の Bug 1(`writeParamFormat(seq[byte])` が format 0)は + **現コードで修正済**(encoding.nim:1764-1765 は `1'i16`、`toPgParam(seq[byte])`:134 も format 1)。 + Bug 2(PgPath/PgPolygon ガード)も修正済(1086,1097 `checkPgBinPayload`)。よって再報告しない。 + +--- + +- 分類: 設計整合性 / セキュリティ(int32 長ラップガードの横断検証) +- 重大度: Low +- 確信度: 確定 +- 場所: async_postgres/pg_types/encoding.nim(全 encoder) +- 事象: + 「int32 長ラップガードの適用漏れ」を全 `toPgBinaryParam` / array encoder で横断集計したところ、 + 内部 int32 プレフィックスを書く **11箇所全てにガードが存在**し、適用漏れは **0件** であった。 + 唯一の設計上の揺れは、payload ガードを helper(`checkPgBinPayload`)でなく等価の ad-hoc 式で + 書いている箇所が 2 ある点(機能差は無い)。 +- 根拠: コード引用と推論 + 内部 int32 長/カウントプレフィックスの全出現箇所(grep `writeBE32\((pos|[0-9]+), int32\(` + nbits 直書き)と、 + それを守るガード: + | 行 | 書き込み | ガード | + |----|----------|--------| + | 298 | `int32(dims.len)` ndim | `validatePgArrayShape`(284) → ndim ≤ PgArrayMaxDim=6(array.nim:60) | + | 312 | `int32(ev.len)` 要素長 | `checkPgBinLen(ev.len)`(294) | + | 376 | `int32(dms.len)` ndim | `validatePgArrayShape`(buildFixedArray:396 / Opt:426 経由) | + | 406 | `int32(esz)` 固定幅 | esz はコンパイル時定数(≤32) | + | 447 | `int32(esz)` 固定幅 | 同上 | + | 882 | `v.nbits`(PgBit 直書き) | `nbits ≤ PgBitMaxBits = 1<<30 < int32.high`(871) | + | 1089 | `int32(v.points.len)` path npts | `checkPgBinPayload(size)`(1086)、size=1+4+n*16 ⇒ n ≤ ~134M < int32.high | + | 1099 | `int32(v.points.len)` polygon npts | `checkPgBinPayload(size)`(1097) | + | 1174 | `int32(v.len)` hstore 対数 | `checkPgBinLen(v.len)`(1164) | + | 1177 | `int32(k.len)` hstore key 長 | `checkPgBinLen(k.len)`(1167) | + | 1184 | `int32(vs.len)` hstore value 長 | `checkPgBinLen(val.get.len)`(1170) | + 可変長配列は全て `encodeBinaryArray`(要素毎 `checkPgBinLen`:294 + 累積 `checkPgBinPayload`:296)または + `buildFixedArray`/`buildFixedArrayOpt`(payload ガード 398/435)を経由。scalar の可変長(string/bytea/jsonb 等)は + 内部長プレフィックスを持たず、外側長は Bind 層の `addLen32`(encoding.nim:1730 → pg_protocol.nim:365-382、 + `> maxInt32Len` で raise)が守る。`toPgBinaryParam` proc 定義は計 **38**(grep `proc toPgBinaryParam`)。 + 設計の揺れ: payload ガード 4箇所のうち 2箇所(296 `checkPgBinPayload`、1086/1097/1172 同)は helper を使うが、 + `buildFixedArray`(398) と `buildFixedArrayOpt`(435) は等価の ad-hoc 式 + `if payload > int32.high.int64: raise newException(PgError, "Array payload too large ...")` を使う + (grep `int32.high.int64` → 263(helper定義),398,435 の3行、うち encoder 本体は 398,435 の2箇所)。 + 境界値は同一(`int32.high.int64`)で機能差は無く、メッセージ文言のみの差異。 +- 系統性: 単発(適用漏れは 0件)。ad-hoc payload ガードの揺れは **2箇所**(398,435)。 + grep パターン `int32.high.int64` = 3行(定義1 + encoder 2)/`checkPgBinPayload` 呼び出し = 4箇所(296,1086,1097,1172)/ + `checkPgBinLen` 呼び出し = 4箇所(294,1164,1167,1170)。 + +--- + +- 分類: セキュリティ(`newSeq[byte](size)` の巨大確保 / ガードのタイミング) +- 重大度: Low +- 確信度: 高 +- 場所: async_postgres/pg_types/encoding.nim:1006-1014(`encodeJsonbBinary`)/根 async_postgres/pg_types/core.nim:1081-1085(`toBytes`) +- 事象: + 非 inline のテキスト/jsonb encoder は、int32 長ガードより **前に** ペイロード全体を `newSeq[byte]` で確保する。 + ~2GiB を超える入力では、プロトコル層のガード(`addLen32`)が raise する前に、入力のコピーが + 一時確保される。inline 経路が示す「ガード→確保」の順序と逆。silent wrap はしない(下流ガードが捕捉)が、 + 巨大入力で約2倍の一時メモリを消費してから `ValueError`。 +- 根拠: コード引用と推論 + 意図された順序(ガード→確保、inline): + ```nim + proc toPgParamInline*(v: string): PgParamInline = # 47 + if v.len > maxInt32Len: # 50: 確保前にガード + raise newException(ValueError, ...) + ... + result.overflow = newSeq[byte](v.len) # 61: ガード通過後のみ確保 + ``` + 逆順(確保→ガード、非 inline): + ```nim + proc encodeJsonbBinary*(node: JsonNode): seq[byte] = # 1006 + let jsonBytes = toBytes($node) # 1010: コピー確保 + result = newSeq[byte](1 + jsonBytes.len) # 1011: 長さガード無し + ``` + `toBytes`(core.nim:1083 `result = newSeq[byte](s.len)`)もガード無しで、`toPgParam(string)`:111 / + `toPgBinaryParam(string)`:617 / `$v` 系テキスト encoder 群(PgTime/PgNumeric/PgInterval/PgInet/幾何型 等)が共用する。 + これらの int32 長検証は下流の `addBind` → `buf.addLen32(data.len, "Bind parameter value")`(encoding.nim:1730) + または配列要素時の `checkPgBinLen`(294)で初めて行われる。つまり ~2GiB 超の string/JsonNode を渡すと、 + encode 段階で同規模のコピーが確保され、Bind 段階で初めて拒否される。入力は既に呼び手が保持しているため + 最悪でも一時 ~2倍・境界値は `maxInt32Len = int(high(int32))`(pg_protocol.nim:291)。DoS 増幅は入力サイズに + 上限付けられ、wrap はしない。 +- 系統性: 同種パターン。可変サイズをガード無しで確保し下流ガードに依存する箇所は **2系統**: + `toBytes`(core.nim:1083、テキスト encoder 約15 proc が共用)と `encodeJsonbBinary`(encoding.nim:1011)。 + 対照: encoding.nim 内の `newSeq[byte]` 全 **24箇所**(grep `newSeq\[byte\]`)のうち、可変長で独自ガードを持つのは + 297/401/438(payload ガード 398/435 等),1087/1098/1173(`checkPgBinPayload` 1086/1097/1172),61(50 で事前ガード); + 92 は UUID 文字列(36バイト固定)で境界自明; 残り(655,660,672,690,774,779,791,799,837,881,1058,1067,1073,1079,1106)は + 固定サイズ。ガード無し可変長は 1011 のみ(+共用根の core.nim:1083)。 diff --git a/audit/findings/lifecycle_notify.md b/audit/findings/lifecycle_notify.md new file mode 100644 index 00000000..ac0ef004 --- /dev/null +++ b/audit/findings/lifecycle_notify.md @@ -0,0 +1,25 @@ +# lifecycle / simple_query / notify 監査所見(Tier 2) + +対象: `async_postgres/pg_connection/lifecycle.nim` (680), `simple_query.nim` (447), `notify.nim` (429) +観点: 並行性・状態、セキュリティ、境界条件、エラーパス。 +前提: asyncdispatch の `cancelAndWait` は no-op(`async_backend.nim:193-204`、doc 自身が "the future is neither cancelled nor awaited" と明記)。chronos は実際にキャンセルする。 + +## 修正ステータス + +本ファイルの所見(`abortListenTask` のゾンビ pump)は修正済みのため削除した。PR #575(`f96a278`)が +`close()` / `stopListening` の graft・ハング経路を、後続の修正が `abortListenTask` の asyncdispatch 分岐 +(closeTransport → bounded await pump、pump 生存時の `listenStopRequested` 保持)をそれぞれ解消している。 +レビュー `reviews/review_listen_close_reconnect.md` の Issue 1–6 も同 PR で対応済み。回帰テストは +`tests/test_listen_reconnect.nim`(両 backend グリーン)。 + +--- + +## 横断結果(lifecycle / simple_query の陰性確認) + +対象3ファイルに未ガード cancelAndWait / orphan タスク復活は**無し**。検証済み項目: + +- **`lifecycle.nim:463` の cancelAndWait は適切にガード済み**。`when hasAsyncDispatch:`(:450)の `else` 分岐(chronos 専用)にあり、asyncdispatch は :450-461 の手動停止(`listenStopRequested` → `closeTransport` → `await pump`)を使う。`test_e2e_listen.nim:296-330` が復活非再発を検証。所見なし。 +- **`simple_query.nim` には cancelAndWait が無い**。`asyncSpawn` は1件(`cancelNoWait` :209、`doCancel` :203-207 が `except CatchableError: discard` で全エラーを飲み、asyncSpawn の `raiseAssert` Defect(async_backend.nim:213-214)に到達しない)。`lifecycle.nim:540` の orphan クローズ spawn も同様に CatchableError を swallow(:546-547)。対象3ファイル内で捕捉不能 Defect を漏らす spawn は無し。 +- **`quoteIdentifier`(simple_query.nim:96-98)は完全**: `"\"" & s.replace("\"", "\"\"") & "\""` は埋め込み二重引用符を二重化して全体を二重引用符で囲む。libpq `PQescapeIdentifier` と同一規則。識別子クォートとしてのエスケープは完全(NUL はサーバ側で拒否される識別子制約)。simple_query は信頼入力前提を doc(:276-277,:307-308)で明記。所見なし。 +- **SCRAM / require_auth(lifecycle.nim:306-415)は防御的**: SASL 開始後の `AuthenticationOk` を `scramFinalVerified` 必須で拒否(:314-319、MITM の SASLFinal スキップ防止)、`SASLContinue`/`SASLFinal` の先行 `AuthenticationSASL` 必須チェック(:373-383,:395-403、nonce バインディングの vacuous pass 防止)、`enforceAuthAllowed` の allowlist(:33-44)と `filterSaslByRequireAuth`(:46-58)+防御的再チェック(:366)、`trust` 認証の allowlist 照合(:312-313)。SCRAM iteration は `pg_auth.nim:148-159` が `< 4096` と `> maxIterations`(既定 10_000_000、pg_auth.nim:17)を拒否し PBKDF2 DoS を上限化(libpq より厳格)。`selectScramMechanism`(:60-124)の channel binding モード分岐と `cbSupportedButUnused`(:112)の "y,," ダウングレード検出は libpq 整合。所見なし。 +- **failover / session attrs(lifecycle.nim:584-676, simple_query.nim:426-447)**: `matchesOrClose`(:481-503)は probe 失敗・非一致・キャンセルの全パスで先に `conn.close()` してから raise/return(接続リーク無し、`ReraiseDefect` 回避のため捕捉例外 `e` を再 raise :498-501)。`probeBool`(simple_query.nim:378-389)は不定結果(0行/NULL)を raise で失敗させ、ホストを黙って一致扱いしない(libpq の次ホスト前進と整合)。全ホスト失敗は `PgConnectionError` に集約(lifecycle.nim:647-649)、単一ホストの `AsyncTimeoutError` は生で再 raise(:645-646、プール側の型分岐契約維持)。`orderedHosts`(:554-582)の lbhRandom は `std/sysrand.urandom` シードで `--threads:on` 安全。所見なし。 diff --git a/audit/findings/mechanical.md b/audit/findings/mechanical.md new file mode 100644 index 00000000..25a7d205 --- /dev/null +++ b/audit/findings/mechanical.md @@ -0,0 +1,204 @@ +# Audit Findings: Tier 3 機械的スキャン(横断分布・実数) + +対象: `/home/fox/git/async-postgres` 全体(async_postgres/ 26,377行・41ファイル、tests/ 42,157行、examples/ 935行・14ファイル)。 +目的: 個別バグではなく**分布と実数の収集**。横断分析のクラスタ化材料。 +方法: rg/grep/wc/git + 読み取り専用スクリプト(関数長・未参照シンボル)。コード変更なし。 + +凡例: 実数 = 機械カウント / 分布 = ファイル別 / 代表3件 = path:line / 系統性 = クラスタ判定。 + +--- + +## シグナル1: TODO/FIXME/HACK/XXX コメント + +- **実数**: ソース (.nim) の**実マーカー = 0**。 + - 大文字厳密一致 (`TODO|FIXME|HACK|XXX`): 0 件。 + - 大文字小文字無視で 5 件ヒットするが**全て偽陽性**: `getXxxArray` 関数名プレースホルダ + (decoding.nim:317, test_types.nim:7954) とテストデータ文字列 `"xxx"` (test_rowdata.nim:80,85,231)。 +- **分布**: ソースにマーカー無し。唯一の負債トラッカーは `reviews/RELEASE_0.4.0_TODO.md` + (**git 未追跡** `??`、コミット履歴なし=作業中文書)。`reviews/` 配下 14 レビューが負債管理の実体。 +- **代表3件**: (実マーカー無しのため該当なし。偽陽性代表: async_postgres/pg_types/decoding.nim:317 `getXxxArray`) +- **最古 (git blame)**: **N/A** — 追跡ソースにマーカーが存在しないため時期特定不能。RELEASE TODO は未追跡で履歴無し。 +- **系統性の判定**: 【文化シグナル】コードベースに inline 負債マーカーが**ゼロ**。負債は外部文書 (reviews/ + RELEASE TODO) + で管理される規律。既存問題の所在はコード注释ではなく reviews/ と本監査 findings にある → 横断分析では + 「コメントに残らない既知問題」の一次ソースとして reviews/RELEASE_0.4.0_TODO.md を突合すべき。 + +--- + +## シグナル2: 抑制/契約 pragma と防御アサーション + +### raises pragma +- **実数**: + - `raises: []` 総出現 **121**(本番 async_postgres/ **57**、tests/ **64**)。 + - 本番の**実 proc 宣言**(`{.raises: [].}` 契約、コールバック型署名を除く): **約13**。 + - `{.push raises` = **0**(push 形式は不使用)。 + - `{.async: (raises: [CatchableError]).}` = **6**(pg_replication:526, async_backend:313/327, pg_largeobject:62 他)。 + - `raises: [具体例外]`(非空・非 CatchableError)= **5**。 +- **分布** (本番 `raises: []` 57 の内訳): + - コールバック**型署名**が多数: pg_connection/types.nim に約30(Trace*/Notify/Notice/Reconnect callback 群)。 + - 実 proc 宣言の集中: pg_pool.nim (1146,1340,1346,1352)、async_backend.nim (46,70,252,273)、 + pg_connection/notify.nim (120,125,283)、pg_connection/buffer_io.nim (112,142)。 +- **代表3件**: + - async_postgres/pg_pool.nim:1146 `proc failAllPending(...) {.raises: [].}`(コメント: 「marked raises:[] so the compiler...」) + - async_postgres/pg_connection/buffer_io.nim:112 `proc dispatchNotification*(...) {.raises: [].}` + - async_postgres/pg_connection/notify.nim:120 `proc newListenError(...): ref PgListenError {.raises: [].}` +- **raises 不一致の疑い(機械的所見)**: `raises: []` は CatchableError のみ抑制し **Defect は抑制しない**。 + 本コードベース自身がこの乖離を明文化: async_postgres/pg_bearssl.nim:31 + `# int(len) traps RangeDefect > high(int); Defect leaks past raises: [] into C (UB).` + → `raises: []` を付けた本番 proc が配列アクセス/整数変換を含む場合、Defect リーク経路となる(シグナル3 と連結)。 + +### doAssert / assert(本番の防御アサーション) +- **実数**: 本番 `doAssert` = **2**(いずれも**静的/コンパイル時**): + - async_postgres/pg_sql.nim:371 `doAssert sqIdx >= 0, ...`(マクロ内、コンパイル時) + - async_postgres/pg_connection/types.nim:710 `doAssert ord(sslnPostgres) == 0`(enum ordinal 表明) + - 本番の裸 `assert(` = **0**。tests/ の `doAssert` = **2884**(テストでは多用、正常)。 +- **系統性の判定**: 【中】本番コードに**実行時防御アサーションが実質ゼロ**。かつては境界検証が + `raise`(catchable)と `cellInfo` の IndexDefect(uncatchable)に二極化していたが、シグナル 3a/3b の + IndexDefect は `PgTypeError` に変換済み(対処済み)。残る `raises: []` × 防御 assert 欠如 × 一部 Defect + リーク認識(bearssl:31)は**同一クラスタ**として横断分析に回す。 + +--- + +## シグナル3: 捕捉不能 Defect を投げうる危険パターン(横断カウント) + +### 3a. cellInfo / isNull の IndexDefect(対処済み・削除) +accessors.nim:6-13 / :103-110 の `IndexDefect` を `PgTypeError` に変換済み。 +cellInfo 経由の約50呼び出し、isNull、query.nim convenience の固定 col=0 アクセス +(query.nim:327, 361, 396, 459 の isNull(0)/getStr(0) 経路)はいずれも +`except PgError` で catchable。3b と統合して解消。 + +### 3b. 固定インデックス row.getStr(N)(対処済み・削除) +query.nim convenience 4 パターンに `numCols == 0` ガードを追加し、 +0 列時は `PgTypeError` を送出するよう変換済み(3a と同 PR)。 +replication 系 10 件は別途「対処済み(削除)」として既出(audit/report.md)。 + +### 3c. newSeq[byte](サーバ由来長) の巨大確保 +- **実数**: `newSeq[byte](` 本番総数 **48**。うち truncating `newSeq[byte](X.int)`(int64→int)= **6**。 +- **分布(X.int 確保)**: ranges.nim:383, 758 / user_types.nim:269 / encoding.nim:1087, 1098, 1173。 + サーバメッセージ長由来確保: pg_protocol.nim:1074 `buf: newSeq[byte](total)`(total は既存 buf 長で上界付き)。 +- **代表3件**: encoding.nim:1087 `var data = newSeq[byte](size.int)` / ranges.nim:383 / pg_protocol.nim:1074。 +- **系統性**: 【中】encode 側の `size.int` 確保 6 件は int64→int 縮小。深度分析は encoding.md / decoding.md(Tier1/2)参照。 + +### 3d. int32(...) / int(...) の truncating cast +- **実数**: `int32(` = **78**、`int(` = **80**、`.int)`(int64→int)= **12**、`.int32)` = **0**。 +- **分布(.int) 12 件)**: newSeq 確保 6(3c と重複)/ ssl.nim:177,178(ALPN protoLen、サーバ交渉由来)/ + dsn.nim:225(権限チェック、良性)/ core.nim:820,852(numeric、クライアント側)。 +- **代表3件**: ssl.nim:177 `result = newString(protoLen.int)` / core.nim:852 `result.setLen(fracStart + v.dscale.int)` / encoding.nim:297。 +- **系統性**: 【中】サーバ由来長への縮小は ssl.nim ALPN(177,178)と 3c 確保群。int32( 78 件の深度は encoding.md 参照。 + +--- + +## シグナル4: 到達不能コード / 未参照の公開要素 + +- **実数**: + - 定義のみで未参照の私有 proc(名前出現=1)= **0**(スクリプト走査、1251 定義中)。 + - `when false` / `if false:` dead branch = **0**。 + - コメントアウトされたコード = **0**(ヒューリスティック 7 件は全て自然文コメント、例: pg_pool.nim:1006 "let the maintenance loop resume...")。 + - 単独 `discard` 行 = **103**(意図的な戻り値破棄 / `else: discard`、dead code ではない)。 +- **分布**: dead code 該当なし。`discard` は accessors/encoding/transaction 等に散在(全て意図的)。 +- **代表3件**: (該当なし。discard 代表: accessors.nim:189 `discard # text, varchar, bytea: fall through`) +- **系統性の判定**: 【低】明らかな dead code は検出されず。コードベースは到達不能コードについて清潔。 + 唯一の「削除済 dead code」痕跡は RELEASE TODO 記載の ranges.nim dead `discard` 分岐(c8fa5eb で削除済)。 + +--- + +## シグナル5: 極端に大きいファイル/関数 + +### ファイル(既知、再確認) +- 本番上位: pg_pool **2310** / encoding **1940** / accessors **1860** / replication **1387** / protocol **1374** / + ranges **1223** / core **1096** / connection/types **963** / decoding **935**。 +- テスト上位: test_types **9107** / test_pool **3639** / test_e2e_transaction **3537** / test_e2e_convenience **2282**。 + +### 関数(100行超の top-level 定義) +- **実数**: top-level 定義 **1251** 件中、本体 **>100行 = 26**(doc コメント除外・ネスト proc 込みのヒューリスティック)。 +- **分布(上位)**: + | 行数 | 箇所 | 定義 | + |---|---|---| + | 437 | pg_pool.nim:1772 | proc notify* | + | 411 | pg_sql.nim:176 | func sqlParams* | + | 355 | transaction.nim:506 | proc savepointNameExpr | + | 315 | lifecycle.nim:128 | proc connectToHost* | + | 261 | ssl.nim:256 | proc establishTls | + | 253 | advisory_lock.nim:367 | template withAdvisoryLockCore | + | 240 | pg_pool.nim:836 | proc acquireImpl | + | 221 | transaction.nim:284 | proc buildRetryDeadlineLoop* | +- 他 100-170 行帯: copyInStreamImpl(170), buildSendPhase(161), executeImpl(158), startReplication(152), + parseIntervalText(135), parsePgOutputMessage(135), scanPlaceholders(130), parsePgMoney(128), sqlParseLoop(128), + parseUriDsn(122), listenPump(120), parseBackendMessage(116), applyParam(116), runReplicationStream(115) 等。 +- **代表3件**: pg_pool.nim:1772 (notify 437) / pg_sql.nim:176 (sqlParams 411) / lifecycle.nim:128 (connectToHost 315)。 +- **系統性の判定**: 【中】巨大 proc はマクロ生成器(sqlParams/savepointNameExpr/sqlParseLoop)と + 状態機械(notify/connectToHost/acquireImpl/establishTls)に二極化。pg_pool.nim はファイル最大(2310)× + 巨大 proc 2 件(notify, acquireImpl)× 最高変更頻度で、**複雑度ホットスポット**。 + +--- + +## シグナル6: ハードコードされた資格情報/URL/パス(軽再確認) + +- **実数**: 本番の**実秘密 = 0**(既確認を再確認)。 + - `password` リテラル: dsn.nim:653 `password = ""`(空既定値のみ)。 + - `secretKey`: PostgreSQL cancel-request のプロトコルフィールド(pg_protocol:98,745 / lifecycle:427 他)、資格情報リテラルではない。 + - URL/ホスト: `127.0.0.1`/`localhost` は DSN 既定値(dsn.nim:176,649)、`postgresql://user:pass@host` は doc コメント例。 + - ハードコード絶対パス: 本番 **0**。 +- **分布**: tests/ に password/postgres:// 参照 **105** 件 = 全てテスト fixture(期待値)。 +- **代表3件**: dsn.nim:653(空 password 既定)/ dsn.nim:176(127.0.0.1 既定)/ dsn.nim:5(doc の URI 例)。 +- **系統性の判定**: 【低】実秘密なし。既存結論と一致。横断分析で追加調査不要。 + +--- + +## シグナル7: examples/ の現 API 一致性(付録C#8) + +- **実数**: 14 サンプル(935行)、全て `import pkg/async_postgres`。**API 不一致 = 0**(目視、CI は両バックエンド编译済みの前提)。 +- **使用シンボル → 現公開 API 照合(全て存在)**: + - connect(2 定義) / connectReplication(2) / query(6) / queryValue(8) / exec(6) / withConnection(1) / + prepare(1) / openCursor(1) / copyIn(4) / copyOut(1) / listen(1) / notify(2) / loCreate(1) / startReplication(1)。 + - `withTransaction`: **macro** として存在(conn=transaction.nim:362 / pool=pg_pool.nim:1785 / cluster=pg_pool_cluster.nim:293)。 + ※proc/func 走査では 0 件に見えるが macro 定義。examples(transaction.nim, large_object.nim)は `conn.withTransaction` を使用、整合。 + - pool 生成: `newPool`(pg_pool.nim:609) + `initPoolConfig`(pg_pool.nim:156)、examples/pool.nim:21 の使用と整合。 +- **分布**: 不一致箇所なし。 +- **代表3件**: examples/pool.nim:21 `await newPool(initPoolConfig(...))` / examples/transaction.nim:46 `withTransaction` / + examples/replication.nim:64 `connectReplication`。 +- **系統性の判定**: 【低】examples は現 API と完全整合。古い API の残存なし。横断分析で追加調査不要。 + +--- + +## シグナル8: コピペされた重複ブロック + +- **実数(クラスタ別)**: + 1. **pumpUntilReady 3 種**: buffer_io.nim:376 / 417 / 450 — 各約35行、`block pumpLoop: while true: nextMessage → ErrorResponse/ReadyForQuery/else → fillRecvBuf` + のループ本体が**3 重複**。差異は nextMessage 呼び出しシグネチャのみ。**重複は `{.dirty.}` injection 制約で強制**と + buffer_io.nim:388-393 に明文化(template 境界をまたげない)。意図的・文書化済。 + 2. **withTransaction*/withSavepoint* マクロ族 = 14**: transaction.nim 6(withTransaction/Retry/Deadline/RetryDeadline + withSavepoint/SavepointDeadline)、 + pg_pool.nim 4、pg_pool_cluster.nim 4。cluster 版は pool へ委譲(pg_pool_cluster.nim:307 `newCall(ident"withTransaction", ...primaryPool...)`)で + 部分的 dedup。conn 版と pool 版は構造的に並行。 + 3. **テキスト/バイナリ parse 重複 = 21**: parse*Text / decode*Binary の対(decoding.nim, ranges.nim, core.nim)。 + 例: parseRangeText(ranges:320)↔decodeRangeBinary(ranges:34)、parseInetText(decoding:676)↔decodeInetBinary(decoding:191)、 + parseHstoreText(decoding:475)↔decodeHstoreBinary(decoding:16)。 + 4. **accessors の text/binary 分岐 = 40**: 40 個の `getXxx*` proc が各々 `isBinaryCol(col)` でテキスト/バイナリ分岐 + (accessors.nim に isBinaryCol 40 箇所)。**最大の分岐重複面**。 + 5. **encoding の encode 重複 = 45**: toPgBinaryParam 38 オーバーロード + テキスト équivalent(encoding.nim)。 +- **代表3件**: buffer_io.nim:376/417/450(pumpUntilReady 3 種)/ transaction.nim:362 vs pg_pool.nim:1785(withTransaction conn/pool 並行)/ + ranges.nim:320 vs ranges.nim:34(parseRangeText/decodeRangeBinary 対)。 +- **系統性の判定**: 【高・設計特性】テキスト/バイナリ二重実装は型システム全体を貫く構造的重複 + (accessors 40 分岐 + parse/decode 21 対 + encode 45)。pumpUntilReady 3 種と言語制約マクロ族(14)は意図的。 + 重複は「バグの温床」というより「契約の一貫性リスク」— ranges.md 所見(テキスト path のみ malformed 拒否漏れ、 + バイナリ path は厳密)がこの重複面の**乖離**を実証済。横断分析では「テキスト/バイナリ非対称」を独立クラスタとして扱う。 + +--- + +## 横断分析へ回すべき系統パターン(サマリ) + +1. **【Defect クラスタ】(対処済み・縮小)**: シグナル2(raises:[] 121 + 本番防御 assert 実質0 + bearssl:31 の Defect リーク認識) + × シグナル3a/3b(cellInfo/isNull IndexDefect、query.nim 固定インデックス)は `PgTypeError` へ変換済み。 + replication 系 10 件も別途対処済み。残存は body 内 Defect が advisory_lock/largeobject の + `except CatchableError` を抜ける 2 件のみ。 +2. **【テキスト/バイナリ非対称クラスタ】**: シグナル8(accessors 40 分岐 / parse-decode 21 対 / encode 45)× + ranges.md 既証(テキスト path のみ malformed 拒否漏れ 8 箇所、バイナリは厳密)。重複面の契約乖離。 +3. **【複雑度ホットスポット】**: シグナル5(pg_pool 2310行・notify 437・acquireImpl 240、巨大 proc 26 件)× + シグナル1(負債マーカー0 → 問題がコードに見えない)× profile(pg_pool 変更頻度103・最高)。 +4. **【外部負債管理】**: シグナル1(inline マーカー0、reviews/ + 未追跡 RELEASE TODO に集約)。 + 既知問題の一次ソースはコード注释でなく reviews/RELEASE_0.4.0_TODO.md。 + +## 未調査 / 留保 +- raises pragma の**実コンパイルによる不一致検証**は未実施(機械スキャン外。`raises: []` proc 内の Defect 生成操作の + 網羅列挙は深度分析が必要)。 +- 関数長はヒューリスティック(ネスト proc/末尾コメント扱い)のため ±数行の誤差あり。相対分布のみ信頼。 +- シグナル4 の「公開 API だが外部から未参照」はライブラリ性質上判定不能(内部未参照の私有 proc のみ走査、0 件)。 diff --git a/audit/findings/pg_protocol.md b/audit/findings/pg_protocol.md new file mode 100644 index 00000000..dc6ccd2d --- /dev/null +++ b/audit/findings/pg_protocol.md @@ -0,0 +1,58 @@ +# Audit Findings: async_postgres/pg_protocol.nim + +対象: `async_postgres/pg_protocol.nim` (1374行) — PostgreSQL ワイヤプロトコル v3 の encode/decode。 +焦点: 受信信頼境界(`parseBackendMessage`:1166 と型別パーサ群)が、信頼できない長さフィールドに対して +メモリ上限制御と 32-bit 安全性を貫けているか。fan-in 28(全模块最大)。 + +総括(系統カウント): 所見は **1系統**(系統A は対処済みにつき削除)。 +- 系統B(Low / 4箇所・32-bit 限定): サーバ由来 int32 を `int` 加算する際、int64 拡張されていない箇所が **4箇所** + (856, 1122, 1156, 1207)。コードは 974 と 1201 の2箇所のみ意図的に int64 拡張しており、32-bit 硬化が不完全。 + +対照: int16 カウントのパーサ(`parseDataRow`:844, `parseRowDescription`:914, `parseParameterDescription`:955, +`parseCopyResponse`:996)は要素数が最大 32767 に天然上限化され安全。 + +対処済み(削除): 系統A(Medium / 3箇所) — parseErrorOrNotice / parseAuthentication SASL / +parseNegotiateProtocolVersion の可変長要素蓄積に上限を導入し、catchable な `PgProtocolError` で拒否する +形に修正済み(`MaxErrorOrNoticeFields = 128`, `MaxSaslMechanisms = 64`, `MaxNegotiateProtocolOptions = 1024`)。 + +--- + +- 分類: 境界条件とエラーパス(正当性 / 設計整合性) +- 重大度: Low +- 確信度: 高 +- 場所: async_postgres/pg_protocol.nim:856,858(parseDataRow)、1122,1156(parseDataRowInto)、1207(parseBackendMessage) +- 事象: + 32-bit ビルド(`int` = 32-bit)で、サーバ由来の int32 長を `int` オフセットに加算する境界チェックが + 比較前にオーバーフローし、切り詰め/サイズ検査を迂回し得る。結果として安全ビルドでは捕捉不能の + `IndexDefect`/`RangeDefect`、`-d:danger` では over-read に至る。コードは 974 と 1201 の2箇所を + int64 へ意図的に拡張し「32-bit でオーバーフローしない」と注釈(1197-1200)しているが、 + 下記の4箇所は拡張されておらず、32-bit 硬化が不完全。64-bit(int=int64)ではオーバーフローせず実害なし + (既存レビュー #2 の addBindRaw int32 と同系統・同様に実害可能性は低いが、こちらは decode 側の信頼境界)。 +- 根拠: コード引用と推論 + ```nim + # 1197-1200 の注釈は int64 拡張を明言するが: + if maxLen > 0 and int64(msgLen) >= int64(maxLen): # 1201: 拡張済み + ... + let totalLen = int(msgLen) + 1 # 1207: 未拡張(maxLen<=0 のテスト経路で msgLen=int32.high 時溢出) + ``` + ```nim + # parseDataRow (856,858) — notify ポンプの full-decode 経路で本番到達可能 + if offset + colLen > body.len: # 856: offset:int + colLen:int32 + raise ... + result.columns[i] = some(@(body.toOpenArray(offset, offset + colLen - 1))) # 858 + ``` + 32-bit で offset(<1 GiB) + colLen(int32.high=2147483647) は int32 を溢出し負にラップ → + `負 > body.len` は偽となり検査を迂回、858 の `toOpenArray` が範囲外インデックスで Defect/over-read。 + 到達経路: `notify.nim:167` の `recvMessage()` → `nextMessage(rowData=nil, skipDataRow=false)` → + `parseBackendMessage` の 'D' 分岐(1237)→ `parseDataRow`。Rogue サーバは LISTEN 中でも 'D' を送れる。 + ```nim + # parseDataRowInto (1122,1156) + if bufBase + dataLen > int32.high: # 1122: 検査そのものが 32-bit で溢出し得る + ... + if pos + colLen > bufEnd: # 1156: pos:int + colLen:int32、1122 を抜けても独立に溢出 + ``` + 1122 は bufBase(<=int32.high) + dataLen(<1 GiB) が 32-bit で溢出し比較前に負ラップ → 2 GiB ガード迂回。 + 1156 も pos(<=bufEnd) + colLen(int32.high) が最大約4 GiB となり独立に溢出する。 +- 系統性: 同種パターン。grep `offset \+ colLen|bufBase \+ dataLen|pos \+ colLen|int\(msgLen\)` で + 未拡張の加算 **4箇所**(856, 1122, 1156, 1207;858/1132/1138 はその下流使用)。 + 対照: int64 拡張済みは grep `int64\(` → **2箇所**(974, 1201)のみ。全て 32-bit ビルド限定。 diff --git a/audit/findings/pipeline_copy.md b/audit/findings/pipeline_copy.md new file mode 100644 index 00000000..8a81910d --- /dev/null +++ b/audit/findings/pipeline_copy.md @@ -0,0 +1,98 @@ +# pipeline.nim / copy.nim 監査所見 + +--- + +- 分類: セキュリティ(信頼境界)/ 並行性・状態 +- 重大度: Medium → **前提崩壊(実サーバでは発生せず)+ 防御的硬化済み(2026-08-14)** +- 確信度: 高(前提検証に不備あり、下記の通り訂正) +- 場所: async_postgres/pg_client/copy.nim:131-134, 379-383 +- 事象: + COPY IN 正常完了パスで、サーバーアボート(ErrorResponse)の最終確認なしに CopyDone を送信する。サーバーが最後の `pollCopyInError` 以降かつ CopyDone 到着前にアボートした場合、サーバーは copy-in モードを離脱済みで CopyDone は不正メッセージとなる。 +- **検証結果(2026-08-14): 実サーバ(PostgreSQL 18.3)ではこの事象は発生しない。** + - ワイヤレベルの実験(生ソケットで stray CopyDone を送信): サーバーは追加の ErrorResponse を一切返さず、次のクエリは正常に処理された。クライアント(本ライブラリ)も desync せず接続は csReady のまま再利用可能。 + - PG 18 のソース(src/backend/tcop/postgres.c、idle 状態のメッセージ分岐)に明記: + `case PqMsg_CopyData: case PqMsg_CopyDone: case PqMsg_CopyFail: /* Accept but ignore these messages, per protocol spec; we probably got here because a COPY failed, and the frontend is still sending data. */` + つまり「COPY 失敗後にフロントエンドが送り続けるコピーメッセージ」はプロトコル仕様上想定されており、サーバーは黙って捨てる(PG 9.1 以降の挙動)。監査所見の前提「サーバーはプロトコル違反として追加の ErrorResponse + ReadyForQuery を返す」は誤り。 + - 影響: 実 PG 相手では desync・残留メッセージは発生しない。既存 e2e テスト("copyIn bad data inside txn" 等)は desync を内包せず正しく復旧していた。 +- **対処済み(防御的硬化、2026-08-14)**: 非標準サーバ(stray copy メッセージに違反応答を返す実装)に対する防御として: + 1. CopyDone 送信前に最終 `pollCopyInError` を追加(callbackError パスの CopyFail 前 poll と対称化。監査所見の非対称指摘を解消)。 + 2. 最終 pump がサーバーエラーで raise した場合に `drainLeftoverToReady` で残留違反応答(ErrorResponse + ReadyForQuery)を消費。部分メッセージがバッファ済みのときのみブロック待機(残りが必ず届くため)、空バッファでは即時復帰(実サーバ相手の正常系に影響なし)。CopyFail パスにも適用。 + 3. 回帰テスト `tests/test_copy_race.nim`(モックサーバが違反応答を返す 2 シナリオ、両 async バックエンド)。 + - 実 PG では全経路が no-op。全 e2e(46 件)・全スイートが両バックエンドで合格。 +- 根拠: + `copyInRawImpl` line 127 の `pollCopyInError` は `sendBuf.len >= copyBatchSize` 時のみ呼ばれる。データが 1 バッチ未満(< 256KB)の場合、poll は一度も実行されず line 133 で CopyDone が送信される: + ```nim + if abortError == nil: + # Flush remaining data + CopyDone in one send + conn.sendBuf.addCopyDone() + await conn.sendBufMsg() + ``` + `copyInStreamImpl` も同構造: line 310 の poll は `sendBuf.len >= batchThreshold` 時のみ。コールバックが空を返して break 後(line 290-291)、line 381 で CopyDone を送信。両者とも CopyDone 前にサーバー状態の最終確認がない。 + 対照的に、callbackError パス(line 340)では CopyFail 送信前に `pollCopyInError` でサーバーアボートを明示確認している。正常完了パスにこの確認がない。 + pumpUntilReady(buffer_io.nim:405-412)は ReadyForQuery で即 raise/break するため、残留メッセージを消費しない: + ```nim + elif pumpMsg.kind == bmkReadyForQuery: + conn.txStatus = pumpMsg.txStatus + if conn.state != csClosed: + conn.state = csReady + readyBody + if queryError != nil: + raise queryError + break pumpLoop + ``` +- 系統性: 同種パターン(2箇所)。`rg -n "addCopyDone" -g "*.nim"` → copy.nim:133, copy.nim:381。いずれも正常完了パスで CopyDone 前に abort 確認なし。callbackError パス(copy.nim:340)のみ poll あり。 + +--- + +- 分類: セキュリティ(CopyData の長さ、巨大確保) +- 重大度: Medium +- 確信度: 確定 +- 場所: async_postgres/pg_client/copy.nim:476 +- 事象: + `copyOutImpl`(バッファリング型 COPY OUT)がサーバーからの CopyData を総量制限なしに蓄積する。1 メッセージあたりは `effectiveMaxMessageSize`(デフォルト 1 GiB, pg_protocol.nim:297)で制限されるが、メッセージ数に上界がなく、悪意/侵害されたサーバーが CopyData を無制限に送信してクライアントを OOM にできる。`copyOutStream`(ストリーム型)はコールバックによるバックプレッシャがあるが、`copyOut`(バッファ型)には緩和策がない。 +- 根拠: + ```nim + of bmkCopyData: + cr.data.add(move(pumpMsg.copyData)) + ``` + `CopyResult.data` は `seq[seq[byte]]`(types.nim:382)。pumpUntilReady は ReadyForQuery まで全メッセージを消費するため、サーバーが CopyDone/CommandComplete/ReadyForQuery を送らなければ蓄積は無限に続く。timeout パラメータは `ZeroDuration`(無制限)がデフォルト。 + per-message cap の存在(pg_protocol.nim:1201: `if maxLen > 0 and int64(msgLen) >= int64(maxLen)` )は信頼境界の意識を示すが、aggregate cap がない。 + ストリーム型(copy.nim:555)は `await callback(move(pumpMsg.copyData))` でコールバックが処理を制御可能。バッファ型にこの緩和がない。 +- 系統性: 単発。バッファリング型 COPY OUT は copy.nim:476 の 1 箇所のみ。ストリーム型(copy.nim:555)はコールバック移譲で影響なし。 + +--- + +- 分類: 並行性・状態(pipeline 途中失敗時の pending ops) +- 重大度: Medium +- 確信度: 高 +- 場所: async_postgres/pg_client/pipeline.nim:520-544(executeImpl), 659-680(executeIsolatedImpl) +- 事象: + パイプライン内で scsMiss op が Execute 時にエラー(例: 23505 unique_violation)で失敗した場合、その op の server-side prepared statement がキャッシュにも pendingStmtCloses にも入らず、セッション終了までサーバー側に残留する。両バリアント(executeImpl / executeIsolatedImpl)で発生。 + executeImpl のコメント(line 526-528)は「the failing op's stmt may never have been Parsed」とするが、Execute 時エラーの場合 Parse/Describe/Bind は全て成功(ParseComplete/ParameterDescription/BindComplete 受信済み)であり statement はサーバーに存在する。prepared statement はセッション状態であり transaction abort でも破棄されない。 +- 根拠: + `executeImpl` line 529-543: + ```nim + for i in 0 ..< activeOpIdx: + addCacheMissOp(i) + if queryError.sqlState in StmtCacheInvalidatingStates and + activeOpIdx < p.ops.len and + p.ops[activeOpIdx].cache in {scsHit, scsShare}: + conn.pendingStmtCloses.add(p.ops[activeOpIdx].stmtName) + conn.removeStmtCache(p.ops[activeOpIdx].sql) + raise queryError + ``` + 失敗 op(activeOpIdx)が scsMiss の場合: `for i in 0 ..< activeOpIdx` の範囲外(回収なし)、invalidation 条件は `{scsHit, scsShare}` のみ(scsMiss は対象外)。結果: 何もされない。 + `executeIsolatedImpl` line 661-679: + ```nim + if opError != nil: + if opError.sqlState in StmtCacheInvalidatingStates and + p.ops[opIdx].cache in {scsHit, scsShare}: + conn.pendingStmtCloses.add(p.ops[opIdx].stmtName) + conn.removeStmtCache(p.ops[opIdx].sql) + errors[opIdx] = opError + elif p.ops[opIdx].cache == scsMiss and not p.ops[opIdx].cacheSuperseded: + conn.addStmtCache(...) + ``` + 同構造: `opError != nil` 分岐内では scsMiss は回収も Close もされない。`elif`(成功パス)には入らない。 + executeIsolatedImpl はエラー後も後続 op を実行し接続は csReady に復帰するため、リークした statement は接続プール経由で長期生存し得る。 +- 系統性: 同種パターン(2箇所)。executeImpl line 539-543 と executeIsolatedImpl line 662-669 は同一の `{scsHit, scsShare}` 限定条件。scsMiss の失敗 op を処理するコードパスが両バリアントに存在しない。`rg -n "cache in \{scsHit, scsShare\}" -g "*.nim"` → pipeline.nim:541, pipeline.nim:663 の 2 箇所。 diff --git a/audit/findings/pool.md b/audit/findings/pool.md new file mode 100644 index 00000000..a92bcd46 --- /dev/null +++ b/audit/findings/pool.md @@ -0,0 +1,70 @@ +# pg_pool.nim 監査所見 + +対象: `async_postgres/pg_pool.nim` (2310行、最大ファイル、変更頻度103) +バックグラウンド: デフォルトバックエンドは asyncdispatch (`async_backend.nim:9`)。asyncdispatch の `cancelAndWait` は no-op (`async_backend.nim:193-204`)、`wait()` はタイムアウト後も内部 future を停止せず `onOrphan` 鉤に依存 (`async_backend.nim:162-191`)。 +既存レビュー `reviews/pg_pool_review.md` (#1 対応済、#2 doc/Low、#3 潜在Low、#4 意図的Info) を踏まえ再調査。下記は既存レビューで確定していない、または系統性証拠のある所見。 + +--- + +- 分類: 並行性・状態・リソース / 正当性 +- 重大度: Medium +- 確信度: 高 +- 場所: async_postgres/pg_pool.nim:570-597 (`maintenanceLoop` replenish フェーズ)、対照 425-429 (`spawnConnectForWaiter` 予約契約)・1046-1048 (acquire キュー時 spawn)・516-519 (`respawnForStrandedWaiter`) +- 状態: **対処済み(2026-08-14)**。replenish の in-flight connect を `pool.active` で予約計上 + (`settleReplenishConnect` 新設、handoff で消費 / park・close・failure・キャンセルで解放)。 + chronos の close() キャンセル経路は `except CancelledError` で予約を解放し re-raise。 + 回帰テスト 3 件(asyncdispatch 2 / chronos 3、`tests/test_pool.nim` "Pool replenish capacity race")。 +- 事象: + メンテナンスループの replenish フェーズは、`needed` 本のコネクションを `active` スロットの予約なしに開く。この in-flight connect は `pool.active` に計上されないため、同時に到着した caller-driven acquire が `pool.active < pool.config.maxSize` 検査 (`950行`) で空きありと判定し、別途コネクションを開ける。両者が完了すると、acquire 側が `active` を maxSize まで占有した上、replenish 側が `needed` 本を `idle` に駐車 (`597行`) し、`size() = idle.len + active` が `maxSize` を超過する。超過分は `idleTimeout`(既定10分)で minSize まで剪定されるまで持続する。 +- 根拠: コード引用と推論 + - replenish は `570-571行` で `let currentTotal = pool.idle.len + pool.active; let needed = max(0, pool.config.minSize - currentTotal)` を計算し、`575-581行` で `connectFuts.add(connect(connCfg))` を `needed` 本発行、`581行 await allFutures(connectFuts)` で懸吊する。この間 `pool.active.inc` は一切ない(`580行` の connect 発行から `595行` の handoff 完了まで in-flight connect はどの計数にも乗らない)。 + - この懸吊中に acquire が走ると、`950行 if pool.active < pool.config.maxSize:` が in-flight replenish connect を見ずに通過し、`957行 pool.active.inc` の後 `965/985行` で別 connect を開く。単一イベントループでも `await allFutures(connectFuts)` の yield 中に acquire は実行されるため競合する。 + - 完了後 `592-597行` は `if pool.closed: closeNoWait / elif tryHandoffToWaiter: active.inc / else: idle.addLast` で、`idle.addLast` 分岐に maxSize 上限検査がない。waiter が居なければ(herd が各自 connect を取得済みなら)replenish connect は全て idle へ駐車される。 + - 具体シナリオ(minSize=maxSize=10 の固定プール): idle=0, active=0 へ枯渇 → メンテナンス tick で needed=10 の connect を in-flight → その window に 10 リクエスト到着、各々 active.inc し connect(active=10)→ 完了後 active=10 + idle=10 = `size()` 20 > maxSize 10。DB の per-user 接続上限(例: 15)を突き抜け、他プール/他ユーザの connect を失敗させ得る。超過は idleTimeout(既定10分)で minSize まで剪定(`548-553行`、`totalCount >= minSize` を守りつつ idle を close)されるまで持続。 + - 予約規律との不整合: `spawnConnectForWaiter` doc `426-429行` は "The caller MUST have already incremented `pool.active` as a capacity reservation before invoking this proc" と明記し、実際に acquire キュー時 spawn は `1047行 pool.active.inc` 後 `1048行 spawnConnectForWaiter()`、`respawnForStrandedWaiter` は `518行 pool.active.inc` 後 `519行` で spawn する。replenish のみこの予約なしに connect を発行する。`newPool` の `637行 connect(cfg.connConfig)` も予約なしだが、プールが呼び出し元へ返る前の単発初期化で並行 acquire が存在しないため違反にならない。 +- 系統性: 同種パターン(予約規律の唯一の違反)。`connect(...)` 発行箇所は5箇所: `452行`(spawnConnectForWaiter、呼び出し元が予約済)・`580行`(replenish、**予約なし**)・`637行`(newPool 単発初期化、並行なし)・`965/985行`(acquire、`957行` で予約済)。並行到達し得る経路で予約を欠くのは `580行` のみ。`pool.active.inc` は6箇所(518/595/916/944/957/1047)だが、いずれも「予約」か「handoff/ping 中の計上」で、replenish の in-flight 期間を覆うものはない。 + テスト空白: maxSize 不変条件の競合テストは sweep 経路 (`tests/test_pool.nim:2203` "concurrent acquires during sweep never overshoot maxSize"、ただし `minSize=0` で replenish が `needed=0` のため発火しない) と ping 経路 (`:1255` "concurrent acquire during health-check ping cannot exceed maxSize") のみ。replenish と caller-driven connect を競合させて `size() <= maxSize` を検証するテストは存在しない。replenish 関連テスト (`:2128`, `:3280`) は connect 失敗/close-race のみで上限超過は未検証。 + grep パターン: `rg -n "connect\(connCfg\)|connect\(cfg\.connConfig\)" async_postgres/pg_pool.nim` → 5件、`rg -n "pool\.active\.inc" async_postgres/pg_pool.nim` → 6件。 + +--- + +- 分類: 並行性・リソース(fire-and-forget タスク管理) +- 重大度: Low +- 確信度: 確定 +- 場所: async_postgres/pg_pool.nim:969-980 (acquireImpl 孤立 connect close の `onOrphan`)、drain 対象 2296-2310 (`close()`) +- 事象: + asyncdispatch で caller-driven connect が acquire budget を超過して `wait(rem, onOrphan=...)` がタイムアウトした際、後から完了した孤立 connect を閉じる `asyncSpawn` (`971行`) が `pendingBackgroundTasks` に登録されない。`close()` の drain ループ (`2296行 while pool.pendingBackgroundTasks.len > 0`) はこのタスクを待たずに返るため、`close()` 返回時点で孤立 connect の socket close が完了している保証がない。socket 自体は `asyncSpawn` により最終的に close されるため恒久リークにはならないが、`close()` の「全リソース解放」保証が asyncdispatch で弱まる。 +- 根拠: コード引用と推論 + - `969-980行`: `onOrphan = proc(fut) = if fut.completed(): asyncSpawn (proc() {.async.} = ... await orphan.close() ...)()`。直前に `pendingBackgroundTasks.add` がない。対照: `closeNoWait` は `304行 add` → `305行 asyncSpawn`、`spawnConnectForWaiter` は `507行 add` → `508行 asyncSpawn` と必ず add-then-spawn。 + - `close()` drain (`2296-2310行`) は `pendingBackgroundTasks` の snapshot-and-clear のみで、登録されていないタスクは可視でない。`963-964行` コメントは "the orphan close mirrors `attemptHostTimed`'s handling" とするが、`attemptHostTimed` 側の同等 close も追跡しない(本所見はプール `close()` の drain 保証との関係で記述)。 + - 発火条件: asyncdispatch 限定(chronos は `982-983行` で `onOrphan` を使わずキャンセル)+ caller-driven connect が budget 超過しつつ後で成功。狭いが実在する経路。 +- 系統性: 同種パターン。pg_pool.nim 内の `asyncSpawn` 発行は4箇所: `305行`(closeNoWait、追跡済)・`508行`(spawnConnectForWaiter、追跡済)・`971行`(孤立 close、**未追跡**)・`1365行`(dispatch 実行、**未追跡** — 既存レビュー #3 が `dispatchBatchImpl` 未追跡を指摘済み)。つまり `asyncSpawn` 4件中2件が `pendingBackgroundTasks` 未登録で、`971行` はレビュー #3 と同根のパターンでありながら未指摘の新規箇所。 + grep パターン: `rg -n "asyncSpawn" async_postgres/pg_pool.nim` → 4件(305/508/971/1365、うち971は複数行呼び出し)、`rg -n "pendingBackgroundTasks\.add" async_postgres/pg_pool.nim` → 2件(304/507)。 + +--- + +- 分類: 並行性・正当性(batch dispatch 予算配分) +- 重大度: Low +- 確信度: 高 +- 場所: async_postgres/pg_pool.nim:1300-1338 (`dispatchBatchImpl`)、1346-1376 (`scheduleDispatch`)、cap 定義 1325-1327 +- 事象: + `dispatchBatchImpl` は開始直後に `pool.dispatchScheduled = false` (`1302行`) へリセットする。このため、ある dispatch が `dispatchHomogeneous` の await(acquire + executeBatch)で懸吊している間に別の `pool.exec`/`pool.query` が `scheduleDispatch` を呼ぶと、`1348行 if pool.dispatchScheduled: return` ガードを通過して2つ目の dispatch が arm され、次の tick で並行に走り始める。両 dispatch が各自 `cap = max(1, maxSize div 2)` (`1327行`) までコネクションを acquire するため、合計で最大 `maxSize` に達し、`1325-1326行` コメントが述べる "Cap total concurrency at half the pool to avoid starving other users" の意図(プール半分の上 cap)が並行 dispatch 下で無効化される。pipelined 以外の `acquire()` 呼び出しが餓死し得る。 +- 根拠: コード引用と推論 + - `1302行 pool.dispatchScheduled = false` は dispatch 開始時(drain 前)に実行される。dispatch はその後 `1329行 await pool.dispatchHomogeneous(ops, cap)` または `1333-1338行 await allFutures([...])` で懸吊する。 + - この懸吊中に `exec`(pipelined 分岐 `1392-1402行`)が `pendingOps.addLast` 後 `1401行 pool.scheduleDispatch()` を呼ぶと、`dispatchScheduled` は既に false なので `1350行` で true にセットされ `1373行 scheduleSoon(cb)` で別 dispatch が予約される。cb → `run` → `dispatchBatchImpl` #2 が #1 の実行中に起動する。 + - `dispatchHomogeneous` は `1271行 let nConns = min(ops.len, max(1, maxConns))` で最大 `maxConns=cap` 本を `1272-1277行` で順次 acquire し保持する。2 dispatch 並行で保持 connect 合計 ≤ 2*cap = maxSize(cap = maxSize div 2、奇数なら maxSize-1)。 + - 再スケジュール経路 (`1360-1361行 if pool.pendingOps.len > 0: pool.scheduleDispatch()`) は dispatch 完了後のため直列だが、早期リセット (`1302行`) が並行 exec 経由の重複 arm を許す。cap は dispatch 呼び出しごとの局所値で、グローバル上 cap が存在しない。 +- 系統性: 単発(`dispatchScheduled` 早期リセットは `1302行` の1箇所、`scheduleDispatch` ガードは `1348行` の1箇所)。ただし pipelined モード(`config.pipelined=true`)の全 `exec`/`query`(4オーバーロード: 1378/1409/1440/1477行)がこの経路を通る。影響は pipelined 負荷が dispatch 完了間隔より速く到着するワークロードに限定され、cap は元来ソフトヒューリスティック(違反してもクラッシュせず、非 pipelined 呼び出しの待ち時間が増すのみ)。 + grep パターン: `rg -n "dispatchScheduled" async_postgres/pg_pool.nim` → 7件(133 宣言、624 newPool 初期化、1302 dispatch 開始時リセット、1344 failPendingAndUnschedule リセット、1348 ガード、1350 セット、2255 close 時リセット)。 + +--- + +## 検証済み・所見なし(系統比較の結果) + +- `waiterCount` 計数は均衡。inc は `1038行`(acquireImpl キュー)の1箇所、dec は `397行`(tryHandoffToWaiter)・`414行`(failLastWaiter)・`832行`(settleAbandonedWaiter)の3箇所、リセットは `2252行`(close)。`settleAbandonedWaiter` (`802-832行`) は `fut.completed()`(handoff 勝者→release、dec せず)/`fut.failed()`(failLastWaiter/close 済→dec せず)/else(pending→cancelled 設定し `waiterCount > 0` ガード付き dec)の3分岐で、asyncdispatch の same-tick 競合(`810-812行` コメント)と chronos の先行キャンセル(`815-820行`)の双方で二重 dec / 負値化を防止。`831行 if pool.waiterCount > 0` ガードが close 後の settle 到達(`822-824行`)でも負値化を防ぐ。負値/残留による FIFO fast-path ガード (`870行`) 永久無効化の経路は確認できなかった。 +- `releaseCore` (`665-690`) と `respawnForStrandedWaiter` (`510-519`) の active 計数は均衡。discard パス (`671-679`) は `active.dec` (`674`) 後 `respawnForStrandedWaiter` が条件付きで `active.inc` (`518`) し spawn、spawn の `finally` (`501-503`) が `if not consumed and pool.active > 0: active.dec` で予約を解放。handoff パス (`685-686`) は active を減算しない(conn の所有者移行のみ)が、これは handoff 元が既に active 計上済みのため整合。 +- double-release ガード (`releaseImpl` `731-735行`) は `conn.borrowed` で既に idle な conn の再 release を no-op 化。handoff 直後の back-to-back 二重 release が in-use conn を再ルーティングする制限は `722-729行` で文書化済み(raw API 固有、`PooledConnHandle`/`with*` が安全経路)。新規所見ではない。 +- sweep 経路の maxSize 超過は `closeNoWait` による yield-free 化で修正済み(`527-529行` コメント、テスト `tests/test_pool.nim:2151/2203`)。ping 中の active 計上 (`916行`) もテスト済 (`:1255`)。close-race 4経路(replenish `:3280` / acquire `:3321` / ping `:3358` / spawn `:3407`)は全て `pool.closed` 再検査と closeCount 計上の回帰テストが存在する。 +- `resetSession` (`307-356`) の CancelledError / CatchableError 分離は対称(両者 `conn.state = csClosed` 設定のみ、close は `releaseCore` の `closeNoWait` に委譲し metrics 二重計上を回避、`350-351/355行` コメント)。既存レビュー #1 対応済の整合が維持されている。`resetSessionAndRelease` (`793-800`) は `finally: conn.release()` で cancel 時も release を保証。 +- `close()` の drain (`2296-2310`) は snapshot-and-clear + while 再チェックで、await 中に追加されたタスクを次周で回収。`2289-2290行` の単一 yield は abandoned-handoff 継続の closeNoWait enqueue を待機(`active > 0` のときのみ、`2284-2288行` 正当化)。asyncdispatch の `cancelAndWait` no-op に起因するメンテナンスループ由来の取りこぼしは既存レビュー #2(Low/doc)の通りで、新規所見としては扱わない。 +- `splitBatchBudget` (`1175-1185`) の `cap==1` 合計超過は `1181-1182行` コメントで意図的(既存レビュー #4、Info)。`batchTimeout` (`1160-1173`) は1つでも ZeroDuration の op があれば batch 全体を無制限化し、有限 op が兄弟の短い deadline に clamp されるのを防ぐ(`1163-1167行`)。`failAllPending` (`1146-1158`) は `raises: []` で asyncSpawn 呼び出し元への漏洩をコンパイル時に封じる。 diff --git a/audit/findings/query_api.md b/audit/findings/query_api.md new file mode 100644 index 00000000..95b2989e --- /dev/null +++ b/audit/findings/query_api.md @@ -0,0 +1,97 @@ +# Audit Findings: async_postgres/pg_client/ — クエリ実行 公開API表面 (Tier 2) + +対象: `async_postgres/pg_client/` の core.nim (622), query.nim (459), exec.nim (172), +prepared.nim (192), cursor.nim (322), direct.nim (646)。queryRecvLoop / extractParams / +flattenInline / toFormatCodes / buildResultFormats / queryDirect・execDirect ゼロアロマクロ。 +受信チェーン(buffer_io → pg_protocol → decoding)は呼び出し側として利用。 + +方法: 6ファイルを通読し、依存ヘルパ(simple_query.nim の awaitOrInvalidate / checkReady、 +buffer_io.nim の pumpUntilReady / nextMessage、cache.nim の addStmtCache、pg_protocol.nim の +addBind/addBindRaw/newRowData/buildResultFormats/BinarySafeOids、encoding.nim の +addBindDirect/addParseDirect/writeParam*/paramOidOf/coerceBinaryParam、accessors.nim の +getStr/isBinaryCol/columnIndex)を実装まで追跡。Tier1 横断パターン (a)〜(d) の再発を確認。 + +総括: 未対応の報告対象 **1件**(Medium x1)。 +- F1 (Medium・確定): rfAuto のワイヤ形式が呼び出し側から見えないキャッシュ状態に依存し + (初回テキスト / 命中バイナリ)、API 間でも不一致(query・queryDirect・pipeline=命中でバイナリ、 + execute・cursor=常にテキスト)。`QueryResult.fields[i].formatCode` / `row.isBinaryCol(i)` が + 実行ごとに揺れる。 + +--- + +- 分類: 公開境界(API契約の不一致・非決定性)/ テキスト/バイナリ格式非対称 (a) +- 重大度: Medium +- 確信度: 確定 +- 場所: + - async_postgres/pg_client/core.nim:369-370 vs 377/388(キャッシュ状態依存) + - 対照 API: async_postgres/pg_client/prepared.nim:107-119(execute、buildResultFormats 不使用=常にテキスト)/ cursor.nim:70-89(openCursor、同=常にテキスト) +- 事象: + rfAuto の実際のワイヤ形式が、(1) 同じ SQL の何回目の実行か(キャッシュ状態)、(2) どの API か、 + で変わり、呼び出し側から制御・予測できない。 + - 同じ `query(sql, rfAuto)` でも、初回(ミス)はテキスト、2回目以降(命中)は binary-safe 列がバイナリ + (core.nim:369-370 が空 resultFormats を cached.resultFormats で上書き)。結果 `QueryResult.fields[i].formatCode` + と `row.isBinaryCol(i)` が実行ごとに 0↔1 で揺れる(値は型付きアクセサでは正しいが、メタデータは不安定)。 + - API 間で意味が不一致: `query` / `queryDirect` / pipeline はキャッシュ命中でバイナリ化する一方、 + `execute`(prepared.nim:107-119)と `openCursor`(cursor.nim:70-89)は buildResultFormats を使わず + rfAuto を常に空形式(テキスト)として送る。同じ `resultFormat = rfAuto` が API によりテキストにも + バイナリにもなる。 + 値が正しい型付きアクセスにおいても成立する契約・一貫性の違反 + (formatCode/isBinaryCol の非決定性、API 間非対称)。 +- 根拠: コード引用と推論 + ```nim + # prepared.nim:107-118 -- execute は rfAuto(@[]) をそのまま送信。buildResultFormats 呼び出しなし + var qr = QueryResult(fields: stmt.fields) + if resultFormats.len > 0: # rfAuto では偽 → formatCode 更新なし(テキストのまま) + let colFmts = deriveColFmts(resultFormats, qr.fields.len) + ... + ``` + ```nim + # cursor.nim:76-86 -- openCursor も resultFormats.len > 0 のみ派生。rfAuto(@[]) では colFormats=nil(テキスト) + if resultFormats.len > 0: + cursor.colFormats = newSeq[int16](cursor.fields.len) + ... + ``` + 対照 core.nim:369-370(query 系キャッシュ命中)は `cached.resultFormats`(= buildResultFormats、バイナリ含む) + を再生。grep `buildResultFormats` の呼び出しは cache.nim:67 の1箇所のみで、prepared/cursor 経路には存在しない + → 両 API は rfAuto でもバイナリ化しないことが構造的に確定。pipeline.nim:262-268 コメントは query/pipeline + 経路のバイナリ再生を「意図」として明記。 +- 系統性: 同種パターン(rfAuto 意味の分岐点 2系統) + - 「rfAuto をキャッシュ命中でバイナリ化」する経路: sendExtendedQuery(core.nim:369-370、query/exec/queryEach/ + queryInline が共有)+ queryDirect(direct.nim:382 `` `effectiveRfSym` = `cachedSym`.resultFormats ``)+ pipeline + (pipeline.nim:269-273)。`cached(.resultFormats)` の実コード出現 = core.nim:370 / direct.nim:382 / + pipeline.nim:271 の3箇所(direct.nim:382 はバッククォート付きのため単純 grep では漏れる、目視確認)。 + - 「rfAuto を常にテキスト」とする経路: prepared.nim executeImpl(:107-118)、cursor.nim openCursorImpl(:76-89)。 + +--- + +## 調査したが所見としなかった項目(透明性のため記録) + +- **direct マクロの `int16()` 変換が addCount16 の防護を迂回**(encoding.nim:1887 `newLit(int16(args.len))`、 + :1912 `newLit(int16(args.len))`、:1937 `buf.addInt16(int16(resultFormats.len))`)。ライブラリは + addCount16(pg_protocol.nim:340-357)で「生の `int16(n)` は既定ビルドで捕捉不能 RangeDefect、 + `-d:danger` では黙ってラップしストリーム desync」と明記し常に ValueError を投げる防護を用意するが、 + direct マクロはこれを使わない。ただし args.len はコンパイル時定数(マクロ引数、極小)、 + resultFormats.len はキャッシュ命中時の cached.resultFormats(= fields.len、RowDescription の + フィールド数はワイヤ int16 で ≤32767)に由来し、いずれも 32767 を超え得ないため実際には溢出しない。 + 規約違反・潜在リスクの単発 Low のため所見から除外。grep `addInt16(int16(` = encoding.nim:1937 の1箇所。 +- **flattenInline / appendInlineParam の `int32(data.len)` 溢出**(core.nim:292)。inline パラメータの + 累積 payload が 2GiB を超えると `int32(data.len)` が捕捉不能 OverflowDefect。受信側は parseDataRowInto + (pg_protocol.nim:1122-1125)が 2GiB を PgProtocolError で防護、addBindRaw(pg_protocol.nim:650)も + int64 算術で境界検査するが、送信側 appendInlineParam には防護なし。ただし1呼び出しで 2GiB 超の + inline パラメータは非現実的。単発 Low のため除外。 +- **PgParamInline の全フィールド公開による自己起因の過読み**(pg_types/core.nim:143-149、oid*/format*/ + len*/inlineBuf*/overflow* 全て `*`)。`len` を実データより大きく偽造すると appendInlineParam + (core.nim:295-298)の `toOpenArray(0, int(p.len)-1)` が域外読み取り(IndexDefect)。ただし doc は + 「Use toPgParamInline to construct」と指示し、サーバ信頼境界ではなく呼び出し側の自己破壊。単発 Low のため除外。 +- **cursor の自前 drain ループの asyncdispatch 孤立継続**(cursor.nim:110,124,128,174,187,196 の + `await conn.fillRecvBuf()`、timeout なし)。外部 `wait(timeout)` 発火時の孤立継続リスクは buffer_io.md + 所見1 がシステム横断パターンとして既に列挙(cursor.nim の6箇所を含む)しており、新規の誤Handlingではない。 + cursor は closeCursorImpl(cursor.nim:241-243)が csClosed を明示的に retire 扱いするなど、むしろ + 平均より慎重。新規所見とせず。 +- **cacheHitColFmts の cachedColFmts フォールバック長不一致**(core.nim:91-107 → queryRecvLoop:457-458 の + `colFmts[i]`)。resultFormats 空かつ numCols>0 で cachedColFmts を返すが、cachedColFmts は addStmtCache + (cache.nim:68-72)で常に fields.len と等しく設定され、qr.fields = cached.fields(同長)のため域内。 + fields.len==0 は queryRecvLoop:451 の `if qr.fields.len > 0` で除外。IndexDefect には至らず所見なし。 +- **queryEach の rowCount 二重計上**疑い(core.nim:536-538 の onRow 内 `rowCount += 1`)。pumpUntilReady + 配信オーバーロードは nextMessage に rowCount=nil を渡す(buffer_io.nim:433)ため nextMessage 側は加算せず、 + onRow のみが加算。二重計上なし(core.nim:534-535 コメントの通り)。所見なし。 diff --git a/audit/findings/raises_contract.md b/audit/findings/raises_contract.md new file mode 100644 index 00000000..58f0e4ac --- /dev/null +++ b/audit/findings/raises_contract.md @@ -0,0 +1,48 @@ +# raises pragma 実コンパイル検証(S1 クラスタ最終クローズ) + +対象: 本番 `raises: []` 実 proc 宣言の全数(10 件)と非空 raises 契約(buffer_io.nim:312 +`raises: [PgProtocolError]`、lifecycle.nim:35 `raises: [PgConnectionError]` ほか)。 +方法: `--experimental:strictDefects` は利用可能な全コンパイラ(2.0.16 / 2.2.4 / 2.2.10 / 2.3.1 devel)に +存在しないため(compiler ソースに strictDefects の痕跡なし)、本文精読による Defect 生成操作の +網羅列挙で代替。catchable 側の契約整合は CI の実コンパイル(両バックエンド)で検証済み。 + +## 結論: Defect リーク経路 0 件(所見なし) + +`raises: []` proc 内の Defect 生成操作(配列アクセス・int 変換・nil deref・overflow)は +全 10 件で遮蔽または非存在。機械スキャンの「raises 不一致の疑い」は実証されず、S1 クラスタは +完全クローズ。 + +### 全数監査の内訳 + +| サイト | 契約 | Defect 経路の処理 | +|---|---|---| +| pg_pool.nim:1306 failAllPending | `raises: []` | body 全体を `try/except Exception` で遮蔽(**Defect <: Exception のため Defect も捕捉**)。コメントに「将来の変更をコンパイル時に捕捉」と明記 | +| pg_pool.nim:1540 failPendingAndUnschedule | `raises: []` | 呼び出しは遮蔽済み failAllPending + フィールド代入のみ | +| pg_pool.nim:1546 scheduleDispatch | `raises: []` | cb 内の asyncSpawn を `try/except Exception` で遮蔽。Defect は PgError に変換され failPendingAndUnschedule へ | +| notify.nim:134 newListenError | `raises: []` | ref オブジェクト構築のみ(OOM 以外の Defect 生成操作なし) | +| notify.nim:139 notifyListenDeath | `raises: []` | `waiter.fail` のみ `try/except Exception` 遮蔽(コメント: asyncdispatch の `Future.fail` は callback chain 経由で `Exception` 効果、151行)。他は代入と raises:[] ユーザコールバック | +| notify.nim:319 failNotifyWaiter | `raises: []` | `waiter.fail` を `try/except Exception` 遮蔽 | +| buffer_io.nim:112 dispatchNotification* | `raises: []` | キュー操作は長さガード付き(popFirst は `len >= notifyMaxQueue > 0`)、`notifyDropped.inc` は `high(int)` ガード付き、`waiter.complete` のみ `try/except Exception` 遮蔽 | +| buffer_io.nim:142 dispatchNotice* | `raises: []` | オブジェクト構築 + raises:[] ユーザコールバックのみ | +| pg_bearssl.nim:27 appendDnCallback | `raises: []`(cdecl) | `int(len)` 変換は `len > csize_t(high(int))` で事前 return(31行コメント: "Defect leaks past raises: [] into C (UB)")。p は UncheckedArray で検査なし、範囲は BearSSL 側の長さ契約 | +| pg_bearssl.nim:60 x509CaptureAppend | `raises: []`(cdecl) | 同様に `len <= csize_t(high(int))` ガード後変換(62-63行)。`oldLen + n` の int 加算 overflow は 2^63 バイト相当で現実的到達性なし | + +### 検証済みの非該当クラス + +- **非空 raises 契約**(nextMessage `[PgProtocolError]` / enforceAuthAllowed `[PgConnectionError]` ほか 5 件): + catchable 集合はコンパイラが検証済み(両バックエンドでビルド成功)。Defect はどの raises 契約にも + 含まれない言語仕様であり、契約不一致ではない。 +- **`{.async: (raises: [CatchableError]).}` 6 件**(async_backend:313,327 / pg_replication:526 / + pg_largeobject:62 ほか): 同上。catchable 側はコンパイル検証済み。 +- **ユーザコールバック型の raises: []**(types.nim 約30 / async_backend:46,70,252,273 / + pg_pool_cluster:24): コールバック内でユーザが Defect を投げればライブラリの raises:[] proc を + 抜けるが、これはユーザコードの契約違反でありライブラリの欠陥ではない。 +- **OOM(OutOfMemDefect)**: 全 proc 共通。Nim のどの raises 契約も OOM を射程外とする。 +- **except Exception の副作用**: Defect を捕捉して捨てる箇所(failAllPending 等)では失敗の + シグナル自体は Future.fail で伝達済み(fail 自体が操作の本体)のため、Defect 消失による + 見逃しは構造的に発生しない。asyncSpawn 内 Defect は PgError に変換して伝達(scheduleDispatch)。 + +### 留保 + +- strictDefects(Defect 効果の機械検証)が利用可能な Nim 3.x 系では再検証の価値あり。 +- pg_bearssl 2 箇所は cdecl 境界であり、ガード式の高さ自体は手動確認のみ(レビュー 2026-08-13)。 diff --git a/audit/findings/replication.md b/audit/findings/replication.md new file mode 100644 index 00000000..fb32da5a --- /dev/null +++ b/audit/findings/replication.md @@ -0,0 +1,47 @@ +# pg_replication.nim 監査所見 + +対象: `async_postgres/pg_replication.nim` (1387行) / v0.3.0 / 変更37 +観点: セキュリティ・信頼境界・公開API・依存 + +## 調査済み・所見なし(背景) + +以下は精査したが問題を認めなかった(推測で埋めないため明記): + +- pgoutput デコーダ (`parsePgOutputMessage` 354-488, `decodeTuple` 330-352): + 全整数読み取りが `readInt16At/32At/64At` → `ensureAvail` (284-293) 経由で + `PgProtocolError`(捕捉可能)を送出。`fromBE16/32/64` (pg_bytes.nim:53-67) は + 境界チェックせず `IndexDefect` を投げ得るが、デコーダは必ずラッパ経由で + 利用しており直接呼び出しはない。 +- 確保増幅 (d): tuple 列数は `readColumnCountAt` (317-323) が `MaxRelationColumns` + (1600) で上限制御。Truncate の `numRels` は `(data.len - pos) div 4` で残り + バッファ内に制限 (468-469)。't'/'b' フィールドの `dataLen` は `readBytesAt` → + `ensureAvail` が `n < 0` と `n > buf.len - pos` を拒否 (288)。さらに CopyData を + 含む全 backend メッセージは framing 層で `effectiveMaxMessageSize`(既定 1 GiB = + `DefaultMaxBackendMessageLen`, pg_protocol.nim:297)により `parseBackendMessage` + (pg_protocol.nim:1201) で上限制御。よって単一メッセージ内の確保はバッファ尺寸 + に有界で、増幅は成立しない。 +- `parseReplicationMessage` (688-721) の生 `decodeInt64` 直接呼び出し (700-702, + 716-717) は、先行する `copyData.len < 25` / `< 18` ガード (697, 713) が全アクセス + 範囲(最大 offset 17 + 8 = index 24)を被覆するため安全。 +- LSN 解析 `parseLsn` (240-263): 空 half・16 有効桁超・32bit 超を `PgTypeError` + 化。`parseTimelineId` (563-575): narrow 前に範囲チェックし `RangeDefect` を回避。 + `receivedEndLsn` (490-502): uint64 加算前に overflow チェック。 +- LSN 状態管理 (types.nim:796-833): `updateReplMaxReceivedLsn` は単調前進のみ、 + `confirmReplFlushed` は received へ clamp 後単調前進。偽造された小さな startLsn + では後退せず、巨大 startLsn も overflow チェック済み。 +- ストリーム中断時の状態 (b/c): `invalidateAbandonedStream` (1002-1023) が + `csBusy`/`csReplicating` を `csClosed` に毒化。`sendMsg`/`fillRecvBuf` 自体も + 輸送失敗時に `csClosed` 化(buffer_io.nim:210-213, 551-553)。`startReplication` + の `conn.state = csBusy` (1288) 直後の `sendMsg` 失敗も sendMsg 側で csClosed 化 + されるため strand なし。asyncdispatch/chronos 非対称は `replFillRecvBuf` + (876-941) に文書化された設計差であり、server keepalive への自動応答は両方で + 機能するため実害ある非対称は確認できず。 + +--- + +## 対処済み(削除) + +- 元・所見1 (Medium): 4 proc(identifySystem, decodeCreateSlotRow, readReplicationSlot, + timelineHistory)の固定列アクセス 11 箇所が列数未検証で `IndexDefect` を漏出。→ + 各 proc に `qr.fields.len < N` 事前検証を追加し、catchable な `PgConnectionError` を + 送出する形に修正済み。 diff --git a/audit/findings/sql_accessors.md b/audit/findings/sql_accessors.md new file mode 100644 index 00000000..bdbbdb1c --- /dev/null +++ b/audit/findings/sql_accessors.md @@ -0,0 +1,70 @@ +# Audit Findings: pg_sql / accessors / array / user_types (Tier 2) + +対象: +- `async_postgres/pg_sql.nim` (586) — `sql""` マクロ(コンパイル時 {expr} 抽出・自動パラメータ化)、`?` プレースホルダ(sqlParams)。SQL 注入耐性が契約。 +- `async_postgres/pg_types/accessors.nim` (1860) — Row アクセサ(getStr/getInt/...、遅延デコードの出口)。 +- `async_postgres/pg_types/array.nim` (183) — PgArray 型・コンストラクタ。 +- `async_postgres/pg_types/user_types.nim` (514) — composite/enum/domain マクロ。 + +方法: 4 ファイルを通読し、疑わしい点は Nim 2.2.10 で `/tmp` 実測して確定(推測で埋めない)。 +既存レビュー `reviews/review_pg_sql.md`(全項目実害なし)を踏まえつつ再調査。 +Tier1 所見(`.audit/findings/decoding.md` F1 date UTC 非対称 / F2 numeric digit 未検証)は既知として扱い、 +本ファイルではアクセサ層で観測される契約違反のみを報告する(root cause が decoding.nim のものはその旨明記)。 + +総括: 報告対象 **0件**(旧 F1 は対処済み、下記参照)。 + +pg_sql.nim の注入耐性({expr} 抽出 / `?` 変換)は全リテラル形式で堅牢であることを実測確認(所見なし)。 +詳細は末尾「調査したが所見としなかった項目」。 + +## 対処済み(削除) + +- **旧 F1 `parseCompositeText` の unterminated 引用フィールド黙受**(user_types.nim:220-238): + 閉じ引用符不在でループを抜けた場合の判定を追加し、閉じ引用直後の非カンマも拒否するよう修正。 + 兄弟パーサ(parseTextArray / parseHstoreText)と同型の raise 契約に対称化。test_types.nim に負テスト 2 件追加。 + +--- + +## 調査したが所見としなかった項目(透明性のため記録) + +- **pg_sql.nim の注入耐性({expr} 抽出 / `?` 変換)— 実測で堅牢を確認、所見なし。** + `sqlParseLoop`(47-174)は SQL 側の状態機械(単一引用 / E-string / 二重引用識別子 / `$$`・`$tag$` ドル引用 / + `--` 行コメント / ネスト可能な `/* */` ブロックコメント)を一貫して管理し、`{` / `?` の特別扱いは + `sNormal` 状態でのみ実行される。実測(実コード import): + - `sqlParams("... b = '?'")` → リテラル内 `?` 保存、外部 `?` → `$1`。 + - E-string(`E'\'?\''`)/ ドル引用(`$$ ? $$`, `$tag$ ? $tag$`)/ 二重引用識別子(`"?"`)/ 行・ネストブロックコメント + 内の `?` は全て保存、外部のみ `$N`。`??` → `?`、`?|`/`?&` 演算子保存。 + - `sql"SELECT '{notaplaceholder}' , {minAge}"` → SQL リテラル内 `{...}` は抽出されず、外部 `{minAge}` のみ `$1` + (注入耐性の核心。実測 query=`SELECT '{notaplaceholder}' , $1`, params=1)。`{{literal}}` → `{literal}`。 + 既存レビュー review_pg_sql.md の 4 指摘(`?` 演算子セット / char リテラルスキップ / typedesc 転送 / E-string 検出) + も実測と整合。raw/triple/char リテラルの `{expr}` 内扱いはプロジェクトのテスト(tests/test_sql.nim:247 等)で + カバー済。`{expr}` 内の `"…"`/`r"…"`/`"""…"""`/`# …`/`#[ … ]#`/char リテラル走査(248-322)は + `}` の早期閉鎖を正しく抑止すると読解で確認。 +- **pg_sql `?-1` の曖昧さ(単発 Low / ドキュメント済みトレードオフ)— 除外。** + `sqlParams("SELECT ?-1")` → `?-` を演算子として保存するため `?` がプレースホルダ化されない(実測 `SELECT ?-1`)。 + 「パラメータ - 1」の意図だとパラメータ数不一致(サーバ実行時エラー)になるが、沈黙の破損・注入にはならない。 + `?-`/`?#` の防御的保存は review_pg_sql.md 指摘1 とドキュメント(pg_sql.nim:181)で既定。単発 Low のため付録A により除外。 +- **accessors の parseInt オーバーフロー — 実測で安全を確認、所見なし。** + `getInt`/`getInt16`/`getInt64` のテキスト経路は `pgTypeErrorOnValueError`(core.nim:308-318)で `ValueError` を + `PgTypeError` へ変換。Nim 2.2.10 で `parseutils.parseInt(s, v)` / `parseBiggestInt` のオーバーフローは + `ValueError: Parsed integer outside of valid range` を送出(実測確定、`OverflowDefect` ではない)。 + したがって捕捉不能 Defect は発生せず、`except PgError` 契約は維持される。コードコメント(accessors.nim:209-212)は正確。 + (`parseAffectedRowsRaw`(accessors.nim:78)の `except ValueError, OverflowDefect` の OverflowDefect 分岐は + 実測上到達不能だが、防御的であり欠陥ではない。) +- **getInt/getFloat の int8/numeric 列に対するテキスト/バイナリ非対称(単発 Low 寄り)— 除外。** + `getInt` バイナリは clen==4(int4)/2(int2) のみ受理し int8 列(8バイト)は raise、テキストは int32 範囲内なら受理。 + 同様に `getFloat` バイナリは float4/8 のみ、テキストは numeric テキストも `pgParseFloat` で受理。 + ただしバイナリ側の raise は「失敗安全」(誤値を返さない)で、getInt は int4 用・getInt64 を使うべきという設計と整合。 + 実害が「raise するか受理するか」の差(沈黙の誤値ではない)で単発・Low 寄りのため付録A により除外。 +- **array.nim(PgArray 型・コンストラクタ)— 所見なし。** + `validatePgArrayShape`(48-83)/`expectedElemCount`(24-46)は dims/lowerBounds/elements 整合性、`PgArrayMaxDim`、 + int32 積オーバーフロー、ndim>0 での 0 次元を `PgTypeError` で検証。空配列(dims=@[])規約も一貫。 +- **getDomain / pgDomain / pgEnum / pgComposite マクロ — 所見なし。** + `getDomain`(488-507)は distinctBase で分岐し未対応基底型はコンパイル時 `{.error.}`(float32 等は意図的未対応、文書化)。 + `pgComposite`(316-336)の `compositeFieldToText`(285-304)は空文字列・`NULL`(大文字小文字無視)・`,()"\ `・ + 空白を含む値を引用し、`"`→`""`・`\`→`\\` をエスケープ(過剰引用は安全、不足引用は無し)。 + `getComposite` バイナリ経路(412-444)は `decodeBinaryField`(381-410)で OID(checkFieldOid)と長さ + (checkFieldLen)を検証し、同幅型の取り違えを抑制。フィード数不一致も双方向で raise。 +- **optAccessor の None 返却条件 — 所見なし。** + `optAccessor`(849-865)は `row.isNull(col)` のみで `none` を返す。`isNull`(103-110)は col 境界を検証し + (越界は `PgTypeError`、catchable)、`scale` 引数は `compiles` 検出で getMoney 系へ転送(fix 71f9e63 と整合、getMoneyArray/ + getMoneyArrayND の Opt も scale 転送)。NULL 以外での黙った None は無し。 diff --git a/audit/findings/ssl.md b/audit/findings/ssl.md new file mode 100644 index 00000000..272c483c --- /dev/null +++ b/audit/findings/ssl.md @@ -0,0 +1,35 @@ +# ssl.nim / pg_bearssl.nim 監査所見 + +対象: `async_postgres/pg_connection/ssl.nim` (612行) 、`async_postgres/pg_bearssl.nim` (209行) +バックグラウンド: デフォルトバックエンドは asyncdispatch=OpenSSL、`-d:asyncBackend=chronos` で BearSSL。 +依存の実機確認: chronos-4.4.0 (`tlsstream.nim`)、bearssl-0.2.12 (`csources/`)、Nim 2.2.10 stdlib (`lib/pure/net.nim`, `lib/std/tempfiles.nim`)。 + +核心の MITM 防御(CA ピン留め、hostname 検証、pre-TLS インジェクション検知、ALPN 強制、require 以上の平文 fallback 拒否)は +両バックエンドで fail-closed に実装済み。バックエンド非対称の所見はすべて対処済み(対応は git log を参照)。 + +--- + +## 補足(所見として起票しない確認事項) + +以下は調査したが所見に該当しなかった、または既存文档と重複するため起票しない。 + +- **CA ピン留めは両バックエンドで正しい**: verify-ca/full で `sslRootCert` 空は `negotiateSSL:534-540` が I/O 前に fail-closed。 + asyncdispatch は `newContext(CVerifyNone)` で OS バンドルを読ませず(stdlib net.nim:711 の `verifyMode != CVerifyNone` ガード)、 + `SSL_CTX_load_verify_locations`(414) でピン留め CA のみ信頼。chronos は `parseTrustAnchors` の自前錨を渡す(303-318行)。 + Web PKI フォールバックによる MITM 経路は無い。 +- **verify-full の hostname 検証は fail-closed**: asyncdispatch は動的シンボル(`SSL_set1_host` 等)未解決時に例外を投げて + 連鎖検証へ降格しない(`enforceVerifyFullIdentity:180-213`)。chronos は BearSSL の名前照合に委ねる。 +- **pre-TLS インジェクション検知(CVE-2021-23214 系)は堅牢**: chronos の2バイト読み(569-574行)と asyncdispatch の + `socketHasPendingData`(594行、buffer_io.nim:684-707 の MSG_PEEK)の二重検知。テスト2件(test_ssl.nim:356,401)で単一/分割書き込みを被覆。 +- **一時 PEM(writeTempPem)は安全**: `createTempFile` は `mode = S_IRUSR or S_IWUSR`(0600) + `O_EXCL`(stdlib tempfiles.nim:88-89)。 + 予測可能なファイル名でも O_EXCL+0600 で symlink 攻撃不可。`newContext` 読込直後に削除(ssl.nim:454-456)、失敗経路も finally で回収(512-513)。 +- **ALPN 強制は direct モードで fail-closed**: `assertAlpnPostgres`(227-241) は空選択/不一致プロトコルを拒否。peer 制御値は + `escape` で NUL 切り詰めを無害化(240行)。chronos は `getSelectedAlpnProtocol`(342)、asyncdispatch は `getSelectedAlpnOpenssl`(483)。 +- **暗号化クライアント鍵のイベントループ凍結対策**: `failPemPassphrase`(89-95) を `SSL_CTX_set_default_passwd_cb` で登録、 + シンボル非在時は `"ENCRYPTED" in config.sslKey` ヒューリスティック(421-431行)で TTY プロンプトを回避。 +- **struct コピー由来の dangling pointer(reconnectInPlace)は対策済み**: `rebindX509Capture`(notify.nim:98-101) が `certDer` と + エンジンの x509 スロットを再バインド。`trustAnchorBufs`/`tlsStream` は ref/seq で共有され生存する。テスト被覆あり(test_ssl.nim:1609-1646)。 +- **テストカバレッジの穴は既存の自己評価文档と重複**: `tests/test_ssl_coverage.md` が `driveTlsHandshake` エラー分岐・ + `assertAlpnPostgres` エラーパス・暗号化鍵拒否・実 TLS ハンドシェイク成功パスの未カバーを正確に列挙済み(RELEASE_TODO T6 相当)。 + これらはモックサーバでは到達不能な fail-closed セキュリティゲートであり、実 TLS 統合テストまたはスタブ化が必要という同文档の総評に同意。 + 本監査からは新規所見として起票しない。 diff --git a/audit/findings/tier3_hubs_remaining.md b/audit/findings/tier3_hubs_remaining.md new file mode 100644 index 00000000..1baf08d4 --- /dev/null +++ b/audit/findings/tier3_hubs_remaining.md @@ -0,0 +1,58 @@ +# Tier 3 残部(ハブ群・cache/type_lookup・transaction_helpers)監査所見 + +対象: ハブ 4(async_postgres.nim 139行 / pg_client.nim 82行 / pg_connection.nim 54行 / pg_types.nim 160行)、 +`pg_connection/cache.nim`(100行)、`pg_connection/type_lookup.nim`(107行)、 +`pg_client/transaction_helpers.nim`(224行)。 +方法: 全文精読 + 呼び出し側ガード確認 + 実コンパイル(asyncdispatch 既定 / chronos 両バックエンド)。 + +## 検証済み・所見なし(系統比較の結果) + +- **evictStmtCache の空キャッシュ Defect なし**: `.head` が nil なら NilAccessDefect(Defect)。呼び出し 5 箇所 + (core.nim:385,426 / pipeline.nim:304 / direct.nim:398 / cache.nim:64 の自己ループ)は全て + `conn.stmtCache.len >= conn.stmtCacheCapacity` ガード内で capacity > 0 を前提 → 表・LRU リスト両方が非空。 + LRU と Table は全更新経路(touch/add/evict/remove)で対で同期。 +- **clearStmtCache の意味論は呼び出し側で充足**: サーバ側 statement を閉じない仕様(doc 明記)に対し、 + 呼び出しは pool resetQuery 成功後(pg_pool.nim:348、外部リセット直後)と reconnectInPlace + (notify.nim:66、新サーバセッション)のみ → サーバ側リークなし。 +- **transaction_helpers の同期維持**: pumpUntilReady は ReadyForQuery 到達後に queryError を raise するため + (buffer_io.nim:447-448)、エラー時もバッファに残置メッセージが無い。ROLLBACK は + `txStatus == tsInFailedTransaction` 時のみ(BEGIN 失敗=呼び出し側の外側トランザクション内では + ROLLBACK しない、正しい)、CancelledError は generic catch より先に re-raise(asyncdispatch では + CancelledError <: CatchableError のため順序が必須)。 +- **phase 追跡のエッジ**: コメントのみの user SQL は EmptyQueryResponse で phase を進めるため、 + 後続 COMMIT の CommandComplete が user タグとして捕捉されない(74-78行、コメント済み設計)。 + 遅延制約違反など COMMIT 自体の失敗は queryError として伝播し ROLLBACK 経路に入る。 +- **type_lookup の注入耐性**: 型名は `$lt$...$lt$` ドル引用に埋め込むが、安全文字集合は + `[A-Za-z0-9_."]` で `$` を拒否 → 引用終端注入不可。`"` はドル引用内ではデータで不活性。 +- **ハブの export はドキュメントと一致**: pg_client.nim は core から 8 シンボルのみ selective export + (doc に「internal helpers は公開 API でない」と明記、実際の core 公開シンボル一覧と一致)。 + async_postgres.nim は import/export 12 モジュールで対称。トップハブは chronos で実コンパイル確認 + (query / execInTransaction / lookupTypeOids の横断使用)。 +- **pg_types.nim の accessor 生成**(accessorPair/arrayPair/elemOptPair/rangeFamily): 名前衝突は + コンパイルで検出される(ハブ自体が両バックエンドでビルド成功)。delegation を + accessors.nim + ranges.nim 双方に見えるようにする意図的 binding(8-10行コメント)は妥当。 + +--- + +- 分類: 公開 API 面 / 名前空間(doc にリンクされるサブモジュールハブの単独 import 時) +- 重大度: Low +- 確信度: 確定(実コンパイルで再現) +- 場所: pg_connection.nim:49-54 / pg_client.nim:65-82 / pg_types.nim:3-6 +- 事象: + サブモジュールハブ単独 import では、公開シグネチャに現れる基盤型が名前解決できない。 + - `import async_postgres/pg_connection`: `BackendMessage` / `Row` / `RowData` / `bmk*` / `psIncomplete` + (pg_protocol 定義)が不可視。`conn.recvMessage()` の戻り値は推論で使用可能だが、型注釈・ + `msg.kind == bmkXxx` 比較は追加 import なしでは書けない。 + - `import async_postgres/pg_client`: さらに `PgConnection` / `QueryResult` / `ResultFormat` / `PgParam` + (pg_connection・pg_types 定義)も不可視。 + - `import async_postgres/pg_types`: `Row` / `RowData`(pg_protocol 定義)が不可視。 +- 根拠: 3 つの最小テストを実コンパイルし、型注釈が undeclared で失敗することを確認 + (`var msg: BackendMessage`、`var qr: QueryResult`、`proc main(row: Row)`)。トップハブ + `import pkg/async_postgres` では全シンボル到達可能(examples 14/14 と CI で確立済み)。 +- 緩和: `import async_postgres/pg_protocol`(または pg_connection)の 1 行追加で解決。 + `PgProtocolError` は `PgConnectionError <: PgError` 階層のため、例外捕捉はハブ単独でも + `except PgError` で機能する(pg_connection は pg_errors を再 export、53行)。 +- 対称性の欠如: pg_types ハブは pg_bytes / pg_errors を意図的に再 export する(core.nim:3-4,6-7 経由)が、 + pg_protocol は再 export しない。意図的な名前空間抑制か見落としかは doc に記述が無く判定不能。 +- 実害: 低。公開エントリ(トップハブ)は完全、値の使用は推論で可。型名を必要とするのは + 低レベル API(recvMessage 系)の型注釈・bmk* 比較・シグネチャ宣言のみ。 diff --git a/audit/findings/transaction.md b/audit/findings/transaction.md new file mode 100644 index 00000000..cfc6b153 --- /dev/null +++ b/audit/findings/transaction.md @@ -0,0 +1,47 @@ +# transaction.nim / transaction_helpers.nim 監査所見 + +対象: +- `async_postgres/pg_client/transaction.nim` (860行) — withTransaction 系6マクロ (conn版) + withSavepoint / withSavepointDeadline、共有ビルダ (buildRollbackCleanup / buildSavepointRollbackCleanup / buildDeadlineAwaitAndTimeout / buildRetryTxLoop / buildRetryDeadlineLoop)、AST ガード (hasReturnStmt / hasLoopEscapeStmt / checkNoBodyEscape)。 +- `async_postgres/pg_client/transaction_helpers.nim` (224行) — execInTransaction / queryInTransaction (パイプライン単一Sync)。 + +バックグラウンド: デフォルトバックエンドは asyncdispatch (`async_backend.nim:9`)。asyncdispatch にはキャンセル原始が無い (`async_backend.nim:79` warning、`cancelAndWait` は 193-204行で no-op)。chronos は真正キャンセル可能。`CancelledError` は asyncdispatch では `object of CatchableError` として定義されるだけ (`async_backend.nim:83`) で実行時からは送出されない。 + +既存レビュー `reviews/review-transaction.md` Issue 2 (cancel cleanup) は branch f2867ac 対応済(各 body try へ `except CancelledError: raise` 追加)を踏まえて再調査した。 + +## 対処済み(削除) + +- 旧・所見1 「transaction conn マクロのサーバ側中断欠如」(Medium・5箇所): transaction.nim の + 226/271/347/428/598 の CancelledError 分岐に `cancelNoWait` + `state = csClosed` を追加、 + pool 版 (`pg_pool.nim:2039-2041,2197-2199`) と対称化。コミット ede7927。 +- 旧・所見2 「未カバーの cancel 分岐4箇所」(Medium・テスト空白): test_transaction_cancel.nim に + withSavepoint / withTransactionDeadline / withTransactionRetry / withTransactionRetryDeadline の + in-flight cancel 時 csClosed 化検証を追加(+5 テスト、chronos ゲート)。同上コミット。 + +--- + +- 分類: 公開境界 / 可観測性(doc とコードの不一致) +- 重大度: Low +- 確信度: 確定 +- 場所: async_postgres/pg_client/transaction.nim:124-127 (doc) vs 141-153 (code) — `buildRollbackCleanup`; 158-162 (doc) vs 176-190 (code) — `buildSavepointRollbackCleanup` +- 事象: + 両ビルダの doc は「invalidated connection の場合、またはサーバが既にトランザクションを終了している場合に ROLLBACK をスキップし、**両方を** `onCleanupSkipped` で報告する」と述べるが、コードが報告するのは `state != csReady`(`csrConnInvalidated`)のみ。「サーバが既に tx を終了」した場合(`state == csReady` かつ `txStatus == tsIdle`)は `if`/`elif` のいずれにも該当せず、イベントを発さず無音でスキップされる。`onCleanupSkipped` を監視に使う運用者は、doc が約束する「両方」の片方しか観測できない。 +- 根拠: コード引用と推論 + - doc 124-127: "skip ROLLBACK on an invalidated connection or when the server already ended the transaction (reporting both via `onCleanupSkipped`)"。 + - code 141-153: `if conn.state != csReady: fireCleanupSkipped(conn, ckTxRollback, csrConnInvalidated)` / `elif conn.txStatus in {tsInTransaction, tsInFailedTransaction}: try: ROLLBACK except: fireCleanupSkipped(csrCleanupFailed)`。`state == csReady ∧ txStatus == tsIdle`(COMMIT が serialization failure で既に tx を終了させた場合等)はどちらの分岐にも入らず、`fireCleanupSkipped` 呼び出しが無い。 + - 報告不能の根拠: `CleanupSkipReason` 列挙型 (`types.nim:511-520`) は `csrConnInvalidated` と `csrCleanupFailed` の2値のみで「サーバが既に tx を終了」を表す理由コードが存在しない。doc の "reporting both" は実装不可能な記述。 + - 実害は限定的: tsIdle スキップは benign(片付ける tx が既に無い)であり、イベント不在は設計判断として正当化し得る。問題は doc が実際の挙動と異なる点(可観測性の過大表明)にある。 +- 系統性: 同種パターン 2箇所(`buildRollbackCleanup`, `buildSavepointRollbackCleanup`)。両者とも doc の "reporting both via" と同一構造の code を持つ。 + grep パターン: `rg -n "reporting both via" async_postgres/` → 2件(transaction.nim:126, 161)。 + +--- + +## 検証済み・所見なし(系統比較の結果) + +- `buildBeginSql` (`core.nim:109-137`) の isolation/access/deferrable 結合は全て有効な PostgreSQL 構文。付加順は `ISOLATION LEVEL ...` → `READ WRITE|READ ONLY` → `DEFERRABLE|NOT DEFERRABLE` で、PostgreSQL が任意順を許容する transaction_mode の列として妥当。`ilReadUncommitted` はサーバが READ COMMITTED へ写像するが構文エラーにはならず、クライアント側の検証責務でもない。5×3×3=45 組合せいずれも `BEGIN` 単独または空白区切りの付加で、二重空白/末尾空白の生成も無い。 +- `isRetryableTxError` (`core.nim:139-146`) は `PgQueryError` かつ SQLSTATE が `states` 一致の場合のみ true。接続断・タイムアウト(`PgQueryError` 以外)は決してリトライ不可で、これは「再利用不能な接続で再試行しない」設計と整合。`buildRetryTxLoop`:277-279 / `buildRetryDeadlineLoop`:355-357 のリトライゲートも `state == csReady ∧ txStatus == tsIdle` を追加し、COMMIT の serialization failure 後(サーバが tx を終了済み → tsIdle)に ROLLBACK を挟まず再試行する経路は doc (466-471) と一致する。 +- `backoffDelayMs` (`core.nim:166-179`) は `pow` の overflow を `min(raw, maxDelayMs.float)` で、負値を `if ms < 0: ms = 0` で、jitter の `rand(ms)` を `ms > 0` ガードで保護。`multiplier <= 0` や巨大 `attempt` でも破綻しない。 +- `hasReturnStmt` (9-22) / `hasLoopEscapeStmt` (24-66) はネストした proc/func/method/iterator/lambda/do/converter/template/macro を走査から除外し、内側 `return`/`break`/`continue` を誤検出しない。`block` 内ラベル無し `break` を安全側(誤検出しない方向)に見逃すことは 33-40行で明記済みの既知妥協で、コンパイラの `UnnamedBreak` 警告が補完する。`checkNoBodyEscape` (68-86) は6マクロ全てから呼ばれ(414, 494, 573, 669, 745, 827)、COMMIT/RELEASE スキップを抑止する。 +- `withTransaction` における「body 例外が cleanup の CancelledError に勝つ」優先順位は意図的かつテスト済み(test_transaction_cancel.nim:108-155 "cleanup CancelledError does not mask the body error" が ValueError 伝播と `csrCleanupFailed` 記録を検証)。`buildRollbackCleanup` 146-149 が cleanup 中の cancel を swallow して原因例外を再送出する設計は、121e10a の分割意図と一致。これは所見ではなく設計判断。 +- `transaction_helpers.nim` の cleanup が `== tsInFailedTransaction`(87行)と、マクロの `in {tsInTransaction, tsInFailedTransaction}`(transaction.nim:143)で異なるのは正当: パイプライン版は BEGIN/user/COMMIT が単一 Sync で全てサーバ駆動のため、`queryError != nil` は必ず ErrorResponse ⟺ ReadyForQuery 'E' 状態に対応し、クライアント側 body 例外で 'T' が残るマクロ版の `tsInTransaction` ケースは発生しない。`pumpUntilReady` (`buffer_io.nim:405-411`) は ReadyForQuery で `txStatus` を確定してから `queryError` を raise するため、cleanup 判定時点で `txStatus` は信頼できる。 +- `withSavepoint` の名前ディスアンビギュエーション (552行 `{nnkStrLit, nnkTripleStrLit, nnkRStrLit}`) は既存レビュー Issue 1 の修正済み(44fa0cf)。3引数形 (562-566) が kind 検査しないのは arity で曖昧性が消えるためで、`withSavepointDeadline` doc (803-806) も任意 string 式を許容する旨を明記しており、不整合ではない。 +- `savepointNameExpr` (506-518) の無名 savepoint 名生成は `portalCounter` の inc 後に `"_sp_" & $counter` で、同一接続上のネスト savepoint に一意名を付与。`quoteIdentifier` (`simple_query.nim:96-98`) で二重引用符エスケープされ、SQL injection はガード済み(e2e 1063, 1659 で検証)。 diff --git a/audit/map.md b/audit/map.md new file mode 100644 index 00000000..68dbd0ae --- /dev/null +++ b/audit/map.md @@ -0,0 +1,54 @@ +# 地図(統合索引) + +詳細は以下を参照: +- モジュール分割・依存グラフ: `.audit/map_modules.md` +- エントリポイント・データフロー: `.audit/map_dataflow.md` +- テスト分布・規約: `.audit/map_tests_conventions.md` +- 外部依存・ビルド/CI: `.audit/map_deps_build.md` + +## A. モジュール分割(要点) +ソース41ファイル + ハブ、約26,516行。グループ構成: +- **L0 基底**: pg_errors (217), pg_bytes (120) +- **L1 プロトコル**: pg_protocol (1374) — ワイヤプロトコル encode/decode +- **L2 型**: pg_types/{core(1096), encoding(1940), decoding(935), accessors(1860), ranges(1223), array(183), user_types(514)} + pg_types.nim ハブ +- **L3 接続**: pg_connection/{types(963), dsn(719), buffer_io(780), ssl(612), cache(100), simple_query(447), lifecycle(680), notify(429), type_lookup(107)} + async_backend(354), pg_auth(359), pg_saslprep(156), pg_bearssl(209) +- **L4 クライアント**: pg_client/{core(622), exec(172), query(459), prepared(192), copy(632), transaction(860), transaction_helpers(224), pipeline(726), cursor(322), direct(646)} +- **L5 高レベル**: pg_pool(2310), pg_pool_cluster(395), pg_largeobject(485), pg_advisory_lock(689), pg_sql(586), pg_replication(1387) +- ハブ(re-export専用、実装なし): async_postgres.nim, pg_client.nim, pg_connection.nim, pg_types.nim + +## B. 依存グラフ(要点) +- **循環依存: なし(DAG)/レイヤ違反: なし**(良好な点) +- fan-in 上位: pg_protocol **28**, async_backend **26**, pg_typesハブ **21**, pg_connectionハブ **17**, pg_errors **13**, pg_client/core **10**, pg_connection/types **9**, pg_types/core **7** +- 特記: pg_bearssl→pg_typesフルハブ結合、pg_connection/types(963行)が pg_auth/pg_types/pg_protocol を一括依存、pg_sql→pg_pool(L5) 片方向依存 + +## C. エントリポイントと制御フロー(要点) +- 公開API: connect (lifecycle:678), query/exec (query:177/exec:69), queryDirect/execDirect (direct:463/580), simpleQuery/simpleExec (simple_query:298/265), sql"" (pg_sql:213), withTransaction系 (transaction:362〜), COPY (copy:169〜), LISTEN/NOTIFY (notify:357/398), pool acquire/release (pg_pool:609/1077), replication (pg_replication:541/1141/1325) +- **受信単一チェーン**: socket → fillRecvBuf (buffer_io:183) → nextMessage (:276) → parseBackendMessage (pg_protocol:1166) → 型別パーサ群。受信駆動は `pumpUntilReady` テンプレート3種 (buffer_io:376/417/450) が共通。 + +## D. データフロー(要点) +- 送信: 利用者値 → toPgParam/toPgBinaryParam (encoding) → addParse/addBind/addBindRaw (pg_protocol:562/579/612) → conn.sendBuf → sendMsg (buffer_io:544) +- 受信: socket → recvBuf → parse → RowData フラットバッファ → QueryResult → Row ビュー → **アクセサ呼び出し時に遅延デコード** (decoding) +- FS接触(クライアント側): readPemFileParam (dsn:214, sslcert/sslkey/sslrootcert 読込・sslkey権限検査), writeTempPem (ssl:361, asyncdispatch一時PEM) +- loImport/loExport (pg_largeobject:209/218) は**サーバ側FS**操作 + +## E. テスト分布(要点) +- 全37 test_*.nim が all_tests.nim に含まれ CI で走る(孤立テストなし)。総量42,157行。 +- e2e16 / モック9 / TLSモック1 / 純粋unit11。 +- 厚い: protocol, types, pool, ssl, auth, network-failure, fuzz。 +- **薄い/無い**: async_backend(354行に対し専用36行), pg_errors(専用なし), pg_bearssl(chronosレグのみ), type_lookup(107), cache(100)。 +- エラーパス: protocol/type/pool/network/ssl は厚い。SQL生成・型roundtrip・sockopt・async抽象は happy path 中心。 + +## F. 外部依存(要点) +- nimble宣言(全て `>=` 下限のみ・上限なし): nim 2.2.4, nimcrypto 0.7.3 (SCRAM/MD5/burnMem), checksums 0.2.2 (MD5), unicodedb 0.13.2 + normalize 0.9.0 (SASLprep) +- **README言及あるが nimble 未宣言**: chronos >= 4.4.0, nim-bearssl >= 0.2.11。CI は `nimble install chronos -y` で無固定。 +- MD5 は nimcrypto にもあるのに checksums を使う軽微な重複。 + +## G. 規約(要点) +- **AGENTS.md / CLAUDE.md なし**。nph フォーマッタを CI で強制(設定ファイルなし・デフォルトスタイル)。 +- 暗黙の慣習: 単一例外階層 PgError (pg_errors)、async_backend の hasChronos/hasAsyncDispatch/hasTls 分岐、`##` RST doc、pg_ snake_case ファイル/PascalCase 型/camelCase proc、re-export ハブ構成、PgTracer nil スキップ型フック。 + +## 構造的所見(地図由来、候補) +1. 依存の上限未固定 + chronos/bearssl 未宣言(再現性・互換性リスク) +2. CHANGELOG なし、v0.3.0 から555コミット先行(semver 運用の不透明さ) +3. async_backend の被参照26に対しテスト36行(ブラスト半径とカバレッジの不均衡) +4. fan-in 集中(pg_protocol 28, async_backend 26)— 基盤変更の波及大 diff --git a/audit/map_dataflow.md b/audit/map_dataflow.md new file mode 100644 index 00000000..d8f0c535 --- /dev/null +++ b/audit/map_dataflow.md @@ -0,0 +1,485 @@ +# エントリポイント / 制御フロー / データフロー マップ + +対象: `async_postgres` (Nim 製非同期 PostgreSQL クライアントライブラリ v0.3.0) +調査種別: 読み取り専用監査。行番号は調査時 (HEAD) のもの。 +パスはすべて `/home/fox/git/async-postgres/` 起点の相対で記載。 + +ライブラリであるため「エントリポイント」とは: +1. 利用者が呼ぶ公開 API (proc/macro/template) +2. PostgreSQL サーバからバイト列が入る経路 (受信パースチェーン) + +--- + +## 1. 公開 API エントリポイント一覧 + +公開シンボルの集約ハブ: `async_postgres.nim:131-139` (import/export)。 +`pg_connection.nim` と `pg_client.nim` はサブモジュールの再エクスポートハブ。 + +### 1.1 接続ライフサイクル + +| proc | 定義ファイル:行 | 用途 | +|---|---|---| +| `connect*(dsn: string)` | async_postgres/pg_connection/lifecycle.nim:678 | DSN 文字列から接続。`connect(parseDsn(dsn))` の短縮形 | +| `connect*(config: ConnConfig)` | async_postgres/pg_connection/lifecycle.nim:584 | マルチホストフェイルオーバ付き接続。`orderedHosts` → `attemptHostTimed` → `connectToHost` | +| `connectToHost*` | async_postgres/pg_connection/lifecycle.nim:128 | 単一ホストブートストラップ: socket → SSL → Startup → 認証ループ → ReadyForQuery | +| `close*(conn)` | async_postgres/pg_connection/lifecycle.nim:446 | 冪等クローズ。listen ポンプ停止、Terminate 送信、トランスポート破棄 | +| `orderedHosts*` | async_postgres/pg_connection/lifecycle.nim:554 | ホスト順決定 (`load_balance_hosts=random` でシャッフル) | +| `parseDsn*` | async_postgres/pg_connection/dsn.nim:708 | URI / keyword=value DSN を `ConnConfig` へ | +| `parseUriDsn*` / `parseKeyValueDsn*` | async_postgres/pg_connection/dsn.nim:525 / 403 | DSN 各形式パーサ | +| `applyParam*` | async_postgres/pg_connection/dsn.nim:286 | 単一接続パラメータ適用 (sslrootcert 等でファイル読込を伴う) | +| `initConnConfig*` | async_postgres/pg_connection/dsn.nim:648 | プログラムによる ConnConfig 構築 | +| `ping*` | async_postgres/pg_connection/simple_query.nim:341 | 空 Query によるヘルスチェック | +| `cancel*` / `cancelNoWait*` | async_postgres/pg_connection/simple_query.nim:151 / 201 | 別ソケットで CancelRequest 送信 (帯域外クエリ取消) | +| `checkSessionAttrs*` | async_postgres/pg_connection/simple_query.nim:426 | target_session_attrs プローブ (primary/standby 判定) | +| `isConnected*` / `socketHasFin*` | async_postgres/pg_connection/buffer_io.nim:709 / 658 | 非ブロッキング生存プローブ (MSG_PEEK) | +| `quoteIdentifier*` | async_postgres/pg_connection/simple_query.nim:96 | 識別子クォート (LISTEN/UNLISTEN 等で使用) | + +### 1.2 クエリ実行 (拡張問い合わせプロトコル) + +| proc | 定義ファイル:行 | 用途 | +|---|---|---| +| `query*(conn, sql, params: seq[PgParam], ...)` | async_postgres/pg_client/query.nim:177 | 型付きパラメータ付き SELECT 系。`QueryResult` 返却 | +| `query*(conn, sql, params: seq[PgParamInline], ...)` | async_postgres/pg_client/query.nim:254 | ヒープ非割当インラインパラメータ版 | +| `queryEach*` | async_postgres/pg_client/query.nim:142 | 行ストリーミング (コールバック per 行、定数メモリ) | +| `queryRowOpt*` / `queryRow*` | async_postgres/pg_client/query.nim:284 / 298 | 先頭 1 行 | +| `queryValue*` (string / T) | async_postgres/pg_client/query.nim:313 / 329 | 先頭行先頭列 | +| `queryValueOpt*` | async_postgres/pg_client/query.nim:347 / 363 | NULL/空許容版 | +| `queryValueOrDefault*` | async_postgres/pg_client/query.nim:381 / 398 / 417 | デフォルト値版 | +| `queryExists*` | async_postgres/pg_client/query.nim:436 | 行存在 bool | +| `queryColumn*` | async_postgres/pg_client/query.nim:446 | 先頭列を seq[string] で | +| `exec*(conn, sql, params: seq[PgParam], ...)` | async_postgres/pg_client/exec.nim:69 | 行を捨てる実行。`CommandResult` (command tag) 返却 | +| `exec*(conn, sql, params: seq[PgParamInline], ...)` | async_postgres/pg_client/exec.nim:128 | インラインパラメータ版 | +| `notify*(conn, channel, payload)` | async_postgres/pg_client/exec.nim:156 | NOTIFY 送信 (payload 空なら `NOTIFY`、あれば `pg_notify`) | + +内部 impl (公開だが内部向け): `queryImpl` query.nim:10/57、`queryInlineImpl` 207、 +`queryEachImpl` 99、`execImpl` exec.nim:9/42、`execInlineImpl` 96。 + +### 1.3 ゼロアロケーション マクロ / simple プロトコル + +| proc/macro | 定義ファイル:行 | 用途 | +|---|---|---| +| `queryDirect*` (macro) | async_postgres/pg_client/direct.nim:463 | パラメータを送信バッファへ直接エンコード | +| `execDirect*` (macro) | async_postgres/pg_client/direct.nim:580 | 同上 exec 版 | +| `simpleQuery*` | async_postgres/pg_connection/simple_query.nim:298 | simple protocol。複数文可、テキスト行のみ | +| `simpleExec*` | async_postgres/pg_connection/simple_query.nim:265 | simple protocol の副作用コマンド (BEGIN/SET/VACUUM 等) | +| `sql*` (macro) | async_postgres/pg_sql.nim:213 | `sql"..."` リテラルの `{expr}` → `$n` 展開 + `seq[PgParam]` 生成 | +| `sqlParams*` | async_postgres/pg_sql.nim:176 | `?` プレースホルダ → `$n` 変換 | +| `pgParams*` (macro) | async_postgres/pg_types/encoding.nim:599 | 複数値から `seq[PgParam]` 一括生成 | + +### 1.4 Prepared Statement / Cursor / Pipeline + +| proc | 定義ファイル:行 | 用途 | +|---|---|---| +| `prepare*` | async_postgres/pg_client/prepared.nim:59 | 明示的 prepare | +| `execute*` (stmt) | async_postgres/pg_client/prepared.nim:135 | PreparedStatement 実行 | +| `close*` (stmt) | async_postgres/pg_client/prepared.nim:185 | stmt クローズ | +| `openCursor*` | async_postgres/pg_client/cursor.nim:303 | カーソル OPEN | +| `fetchNext*` | async_postgres/pg_client/cursor.nim:202 | FETCH NEXT | +| `close*` (cursor) | async_postgres/pg_client/cursor.nim:260 | カーソル CLOSE | +| `withCursor*` (template) | async_postgres/pg_client/cursor.nim:267 | カーソル scoped ヘルパ | +| `newPipeline*` | async_postgres/pg_client/pipeline.nim:89 | Pipeline 生成 | +| `addExec*` / `addQuery*` | async_postgres/pg_client/pipeline.nim:118/148/155 | 文の積込 | +| `execute*` / `executeIsolated*` | async_postgres/pg_client/pipeline.nim:563 / 695 | パイプライン一括実行 | + +### 1.5 トランザクション + +| macro/proc | 定義ファイル:行 | 用途 | +|---|---|---| +| `withTransaction*` | async_postgres/pg_client/transaction.nim:362 | BEGIN/COMMIT/ROLLBACK scoped | +| `withTransactionRetry*` | async_postgres/pg_client/transaction.nim:435 | リトライ付き | +| `withSavepoint*` | async_postgres/pg_client/transaction.nim:520 | SAVEPOINT 嵌套 | +| `withTransactionDeadline*` | async_postgres/pg_client/transaction.nim:622 | deadline 付き | +| `withTransactionRetryDeadline*` | async_postgres/pg_client/transaction.nim:698 | retry + deadline | +| `withSavepointDeadline*` | async_postgres/pg_client/transaction.nim:782 | savepoint + deadline | +| `buildBeginSql*` | async_postgres/pg_client/core.nim:109 | BEGIN SQL 生成 | +| `execInTransaction*` / `queryInTransaction*` | async_postgres/pg_client/transaction_helpers.nim:110/138 / 166/196 | コールバック形式トランザクションヘルパ | + +### 1.6 COPY + +| proc/template | 定義ファイル:行 | 用途 | +|---|---|---| +| `copyIn*` (seq[byte] / openArray / string / seq[seq[byte]]) | async_postgres/pg_client/copy.nim:169 / 196 / 208 / 218 | COPY ... FROM STDIN (一括) | +| `copyInStream*` | async_postgres/pg_client/copy.nim:408 | COPY IN ストリーミング (CopyInCallback) | +| `copyOut*` | async_postgres/pg_client/copy.nim:498 | COPY ... TO STDOUT (一括 CopyResult) | +| `copyOutStream*` | async_postgres/pg_client/copy.nim:593 | COPY OUT ストリーミング (CopyOutCallback) | +| `makeCopyInCallback*` / `makeCopyOutCallback*` | async_postgres/pg_connection/buffer_io.nim:94 / 84 | バックエンド横断コールバック生成 | + +### 1.7 LISTEN / NOTIFY + +| proc | 定義ファイル:行 | 用途 | +|---|---|---| +| `listen*` | async_postgres/pg_connection/notify.nim:357 | チャネル購読 + バックグラウンドポンプ開始 | +| `unlisten*` | async_postgres/pg_connection/notify.nim:366 | 購読解除 | +| `waitNotification*` | async_postgres/pg_connection/notify.nim:398 | プル型通知待機 (timeout/overflow 検出) | +| `onNotify*` | async_postgres/pg_connection/notify.nim:34 | プッシュ型コールバック登録 | +| `onListenError*` | async_postgres/pg_connection/notify.nim:38 | ポンプ永久死通知 | +| `startListening*` / `stopListening*` | async_postgres/pg_connection/notify.nim:268 / 292 | ポンプ手動制御 | +| `reconnectInPlace*` | async_postgres/pg_connection/notify.nim:49 | 同一オブジェクトで再接続 + 再 LISTEN | + +### 1.8 プール / クラスタ + +| proc/template/macro | 定義ファイル:行 | 用途 | +|---|---|---| +| `newPool*` | async_postgres/pg_pool.nim:609 | プール生成 | +| `initPoolConfig*` | async_postgres/pg_pool.nim:156 | PoolConfig 構築 | +| `acquire*` / `acquireHandle*` | async_postgres/pg_pool.nim:1077 / 1102 | 接続取得 | +| `release*` (conn / handle) | async_postgres/pg_pool.nim:759 / 779 | 接続返却 | +| `withConnection*` (template) | async_postgres/pg_pool.nim:1114 | scoped 接続利用 | +| `resetSession*` / `resetSessionAndRelease*` | async_postgres/pg_pool.nim:307 / 793 | セッション状態リセット | +| `exec*` / `query*` / `queryEach*` / `queryRow*` / `queryValue*` 等 | async_postgres/pg_pool.nim:1378-1772 | プール経由クエリ群 | +| `withTransaction*` 系 (pool) | async_postgres/pg_pool.nim:1785 / 1860 / 1927 / 2078 | プールトランザクションマクロ | +| `withPipeline*` (pool) | async_postgres/pg_pool.nim:2210 | プールパイプライン | +| `close*` (pool) | async_postgres/pg_pool.nim:2220 | プールクローズ | +| `metrics*` / `idleCount*` / `activeCount*` 等 | async_postgres/pg_pool.nim:250 / 230 / 234 | 監視アクセサ | +| `newPoolCluster*` | async_postgres/pg_pool_cluster.nim:87 | 読み取りレプリカクラスタ生成 | +| `readConnection*` / `writeConnection*` | async_postgres/pg_pool_cluster.nim:241 / 259 | 振分接続取得 | +| `withReadConnection*` / `withWriteConnection*` | async_postgres/pg_pool_cluster.nim:274 / 284 | scoped 振分 | +| `withTransaction*` 系 (cluster) | async_postgres/pg_pool_cluster.nim:293 / 311 / 332 / 350 | クラスタトランザクション | +| `close*` (cluster) | async_postgres/pg_pool_cluster.nim:376 | クラスタクローズ | + +### 1.9 Advisory Lock + +| proc/macro/template | 定義ファイル:行 | 用途 | +|---|---|---| +| `advisoryLock*` / `advisoryTryLock*` / `advisoryUnlock*` | async_postgres/pg_advisory_lock.nim:140 / 146 / 154 (int64)、238 / 249 / 260 (int32×2) | セッション排他ロック | +| `advisoryLockShared*` / `advisoryTryLockShared*` / `advisoryUnlockShared*` | async_postgres/pg_advisory_lock.nim:164 / 172 / 181、273 / 284 / 295 | 共有ロック | +| `advisoryUnlockAll*` | async_postgres/pg_advisory_lock.nim:190 | 全ロック解除 | +| `advisoryLockXact*` / `advisoryTryLockXact*` (+Shared) | async_postgres/pg_advisory_lock.nim:200 / 207 / 218 / 227、308 / 319 / 332 / 343 | トランザクションスコープ | +| `withAdvisoryLock*` (macro 4 種) | async_postgres/pg_advisory_lock.nim:421 / 442 / 467 / 491 | scoped ロック | +| `withAdvisoryLockShared*` (macro 4 種) | async_postgres/pg_advisory_lock.nim:519 / 540 / 566 / 592 | scoped 共有 | +| `withAdvisoryLockXact*` / `withAdvisoryLockXactShared*` (template) | async_postgres/pg_advisory_lock.nim:623-653 / 656-686 | トランザクション scoped | + +### 1.10 Large Object + +| proc/template | 定義ファイル:行 | 用途 | +|---|---|---| +| `loCreate*` / `loUnlink*` | async_postgres/pg_largeobject.nim:107 / 117 | 生成 / 削除 | +| `loOpen*` / `loClose*` | async_postgres/pg_largeobject.nim:125 / 139 | ハンドル開閉 | +| `loRead*` / `loWrite*` | async_postgres/pg_largeobject.nim:147 / 165 | 読書 | +| `loSeek*` / `loTell*` / `loTruncate*` | async_postgres/pg_largeobject.nim:176 / 190 / 199 | 位置操作 | +| `loImport*` / `loExport*` | async_postgres/pg_largeobject.nim:209 / 218 | **サーバ側**ファイルとの入出力 | +| `loReadAll*` / `loWriteAll*` / `loSize*` | async_postgres/pg_largeobject.nim:230 / 249 / 275 | 一括操作 | +| `loReadStream*` / `loWriteStream*` | async_postgres/pg_largeobject.nim:323 / 342 | ストリーム操作 | +| `lo*Deadline*` 系 | async_postgres/pg_largeobject.nim:390 / 405 / 430 / 447 / 465 | deadline 版 | +| `withLargeObject*` (template) | async_postgres/pg_largeobject.nim:301 | scoped ハンドル | +| `makeLoReadCallback*` / `makeLoWriteCallback*` | async_postgres/pg_largeobject.nim:46 / 70 | コールバック生成 | + +### 1.11 Replication + +| proc/template | 定義ファイル:行 | 用途 | +|---|---|---| +| `connectReplication*` (config / dsn) | async_postgres/pg_replication.nim:541 / 554 | replication パラメータ付き接続 | +| `identifySystem*` | async_postgres/pg_replication.nim:579 | IDENTIFY_SYSTEM | +| `createReplicationSlot*` / `dropReplicationSlot*` / `readReplicationSlot*` | async_postgres/pg_replication.nim:609 / 629 / 643 | スロット管理 | +| `timelineHistory*` | async_postgres/pg_replication.nim:664 | TIMELINE_HISTORY | +| `startReplication*` | async_postgres/pg_replication.nim:1141 | 論理レプリケーションストリーム開始 | +| `startPhysicalReplication*` | async_postgres/pg_replication.nim:1325 | 物理レプリケーション開始 | +| `stopReplication*` | async_postgres/pg_replication.nim:1294 | ストリーム停止 | +| `sendStandbyStatus*` / `sendCopyData*` | async_postgres/pg_replication.nim:761 / 723 | Standby Status Update / 生 CopyData 送信 | +| `confirmFlushed*` / `confirmedFlushLsn*` | async_postgres/pg_replication.nim:801 / 788 | flush 位置確認 (at-least-once) | +| `parseReplicationMessage*` | async_postgres/pg_replication.nim:688 | CopyData → XLogData/PrimaryKeepalive | +| `parsePgOutputMessage*` / `decodePgOutput*` | async_postgres/pg_replication.nim:354 / 504 | pgoutput 論理デコード | +| `parseLsn*` / `makeReplicationCallback*` | async_postgres/pg_replication.nim:240 / 515 | LSN パース / コールバック生成 | + +### 1.12 型変換・行アクセス (利用者向けデコード API) + +| proc/macro | 定義ファイル:行 | 用途 | +|---|---|---| +| `toPgParam*` (多数のオーバーロード) | async_postgres/pg_types/encoding.nim:108 以降 (string:108, int32:116, bool:131, DateTime:137, JsonNode:219, seq 系:552 以降...) | Nim 値 → テキスト形式 PgParam | +| `toPgBinaryParam*` | async_postgres/pg_types/encoding.nim:614 以降、ranges.nim:488 以降 | バイナリ形式 PgParam | +| `toPgParamInline*` | async_postgres/pg_types/encoding.nim:8 以降 | インライン (ゼロアロ) パラメータ | +| `initRow*` / `clone*` | async_postgres/pg_protocol.nim:310 / 1052 | Row 生成 / 深コピー | +| `isNull*` | async_postgres/pg_types/accessors.nim:103 | NULL 判定 | +| `getStr*` / `getInt*` / `getInt16*` / `getInt64*` | async_postgres/pg_types/accessors.nim:133 / 192 / 223 / 249 | スカラーアクセス | +| `getFloat*` / `getFloat32*` / `getBool*` / `getBytes*` | async_postgres/pg_types/accessors.nim:275 / 297 / 416 / 433 | 同上 | +| `getNumeric*` / `getMoney*` / `getUuid*` | async_postgres/pg_types/accessors.nim:317 / 327 / 407 | 同上 | +| `getTimestamp*` / `getDate*` / `getTimestampTz*` / `getTime*` / `getTimeTz*` | async_postgres/pg_types/accessors.nim:457 / 472 / 486 / 501 / 516 | 時刻系 | +| `getJson*` / `getInterval*` / `getInet*` / `getCidr*` / `getMacAddr*` / `getMacAddr8*` | async_postgres/pg_types/accessors.nim:531 / 546 / 557 / 569 / 581 / 591 | 複合系 | +| `getBit*` / `getTsVector*` / `getTsQuery*` / `getXml*` / `getHstore*` | async_postgres/pg_types/accessors.nim:601 / 632 / 642 / 651 / 661 | 同上 | +| 幾何系 `getPoint*`〜`getCircle*` | async_postgres/pg_types/accessors.nim:670-845 | point/line/lseg/box/path/polygon/circle | +| 配列系 `get*Array*` / N 次元 `getArrayND*` | async_postgres/pg_types/accessors.nim:1124 以降 / 1455 | 配列デコード | +| `get*(row, col, typedesc[T])` 総称群 | async_postgres/pg_types/accessors.nim:1614 以降、pg_types.nim:156 (Option[T]) | 型ディスパッチ | +| 名前ベースアクセス群 (`nameAccessor` 展開) | async_postgres/pg_types.nim:61-160 | `row.getStr("name")` 等 | +| `len*` / `columnIndex*` / `rows*` / `items*` (QueryResult) | async_postgres/pg_connection/simple_query.nim:35 / 39 / 43 / 53 | 結果反復 | +| `initCommandResult*` / `affectedRows*` | async_postgres/pg_types/accessors.nim:86 / 89 | CommandResult | + +### 1.13 認証 (公開だが主に内部使用) + +| proc | 定義ファイル:行 | 用途 | +|---|---|---| +| `md5AuthHash*` | async_postgres/pg_auth.nim:43 | MD5 認証ハッシュ | +| `scramClientFirstMessage*` | async_postgres/pg_auth.nim:64 / 93 | SCRAM client-first | +| `scramClientFinalMessage*` | async_postgres/pg_auth.nim:114 | SCRAM client-final (PBKDF2 同期実行) | +| `scramVerifyServerFinal*` | async_postgres/pg_auth.nim:340 | サーバ署名検証 (相互認証) | +| `computeTlsServerEndpoint*` | async_postgres/pg_auth.nim:322 | channel binding データ (RFC 5929) | +| `selectScramMechanism*` / `enforceAuthAllowed*` / `filterSaslByRequireAuth*` | async_postgres/pg_connection/lifecycle.nim:60 / 33 / 46 | 機構選択・require_auth ポリシー | + +--- + +## 2. サーバ入力のパース経路 (制御フロー) + +### 2.1 呼び出しチェーン (ファイル:行) + +``` +socket (kernel) + └─ fillRecvBuf buffer_io.nim:183 ← 唯一の受信 await 点 + ├─ chronos: conn.reader.readOnce (buffer_io.nim:202/204) + └─ asyncdispatch: conn.socket.recvInto (buffer_io.nim:229/231) + (失敗時 conn.state = csClosed に遷移: 212/239) + (chronos レプリケーション向け: fillRecvBufDetached buffer_io.nim:248) + └─ nextMessage buffer_io.nim:276 ← 同期パース。recvBuf を走査 + └─ parseBackendMessage pg_protocol.nim:1166 + ├─ フレーミング: msgType=buf[0], msgLen=decodeInt32(buf,1) (1192-1193) + ├─ maxLen 超過拒否 (1201-1205) / 不完全なら psIncomplete (1189/1208) + └─ msgType 別ディスパッチ (1219-1278): + 'R' → parseAuthentication pg_protocol.nim:787 + (0=Ok, 3=Cleartext, 5=MD5+salt, 10=SASL機構列, + 11=SASLContinue, 12=SASLFinal) + 'K' → parseBackendKeyData pg_protocol.nim:825 + 'C' → parseCommandComplete pg_protocol.nim:832 + 'D' → rowData != nil: parseDataRowInto pg_protocol.nim:1099 + (RowData フラットバッファへ in-place) + skipDataRow: フレーミングのみ (1231-1235) + else: parseDataRow pg_protocol.nim:837 + 'E' → parseErrorOrNotice(isError=true) pg_protocol.nim:861 + 'N' → parseErrorOrNotice(isError=false) pg_protocol.nim:861 + 'A' → parseNotification pg_protocol.nim:887 + 'S' → parseParameterStatus pg_protocol.nim:899 + 'T' → parseRowDescription pg_protocol.nim:906 + 'Z' → parseReadyForQuery pg_protocol.nim:932 + 't' → parseParameterDescription pg_protocol.nim:946 + 'v' → parseNegotiateProtocolVersion pg_protocol.nim:963 + '1'/'2'/'3'/'I'/'n'/'s' → 固定メッセージ (1254-1265) + 'G'/'H'/'W' → parseCopyResponse pg_protocol.nim:985 + 'd' → CopyData (本文をそのまま copyData へ) (1272-1274) + 'c' → CopyDone (1275-1276) + その他 → PgProtocolError (1277-1278) + └─ nextMessage 内の副次ディスパッチ (buffer_io.nim:325-360): + psDataRow → onRow コールバック / rowCount 加算 / rowData 蓄積 + Notification → dispatchNotification buffer_io.nim:112 + (notifyQueue 入隊、overflow 時は古いものを drop、 + notifyWaiter 完了、notifyCallback 起動) + NoticeResponse → dispatchNotice buffer_io.nim:142 + ParameterStatus→ conn.serverParams 記録 buffer_io.nim:345-350 + NegotiateProto → conn.negotiatedMinorVersion 記録 (351-356) +``` + +`recvMessage` (buffer_io.nim:362) = `nextMessage` + `fillRecvBuf` のループ。 + +### 2.2 パンプループ (各操作の受信制御フロー) + +`pumpUntilReady` テンプレート 3 過負荷 (buffer_io.nim:376 / 417 / 450): +- `nextMessage` でメッセージを消費し、`bmkErrorResponse` → `newPgQueryError` + (types.nim:835) 生成、`bmkReadyForQuery` で `conn.txStatus` 更新 + `csReady` + 遷移して終了。DataRow は `nextMessage` 内で消費され body には現れない。 + +| 利用者 | 経路 | +|---|---| +| 拡張 query | queryImpl (query.nim:10/57) → `queryRecvLoop` (core.nim:434) → pumpUntilReady (RowData 蓄積) → QueryResult | +| queryEach | queryEachImpl (query.nim:99) → `queryEachRecvLoop` (core.nim:503) → ストリーミング過負荷 (onRow) | +| 拡張 exec | execImpl (exec.nim:9/42) → `execRecvLoop` (core.nim:580) → 素過負荷 (skipDataRow) | +| direct 系 | queryDirectImpl (direct.nim:32) / execDirectRunImpl (direct.nim:540) → 同上 recv ループ | +| simpleQuery/simpleExec | simple_query.nim:102 / 131 → pumpUntilReady | +| ping | simple_query.nim:341 → EmptyQueryResponse 期待 | +| COPY IN | copy.nim: copyInRawImpl:72 / copyInStreamImpl:237 (CopyInResponse 待ち → CopyData 送信 → CommandComplete) | +| COPY OUT | copy.nim: copyOutImpl:447 / copyOutStreamImpl:520 (CopyData 受信 → callback/蓄積) | +| prepare/execute/close | prepared.nim (prepareImpl:28, executeImpl:78, closeImpl:167) | +| cursor | cursor.nim: fetchNext:202 / close:260 | +| pipeline | pipeline.nim: execute:563 / executeIsolated:695 | +| 接続時認証 | lifecycle.nim: authLoop 306-415 / readyLoop 418-436 | +| LISTEN ポンプ | notify.nim: listenPump:147 → recvMessage (362) → dispatchNotification | +| レプリケーション | pg_replication.nim: runReplicationStream:1025 → nextMessage → bmkCopyData → handleReplicationData:970 → parseReplicationMessage:688 → callback。XLogData.data は利用者が `decodePgOutput`:504 → `parsePgOutputMessage`:354 で pgoutput デコード | + +### 2.3 型デコード (サーバ由来バイト → Nim 値) + +受信時はまず `RowData` (pg_protocol.nim:141) フラットバッファにセルが +`(offset,len)` 列で蓄積されるだけ。**実際のデコードは利用者の Row アクセサ +呼び出し時に遅延実行**される: + +``` +Row アクセサ (accessors.nim) + ├─ テキスト形式: fromPgText (decoding.nim:55) / parse*Text 群 + │ parseTimestampText:366, parseDateText:399, parseTimeText:411, + │ parseTimeTzText:441, parseHstoreText:475, parseIntervalText:540, + │ parseInetText:676, parsePointText:864, parseTextArray:897 + └─ バイナリ形式 (rfBinary / BinarySafeOids): + decodeNumericBinary:63, decodeBinaryTimestamp:94, decodeBinaryDate:127, + decodeBinaryTime:150, decodeBinaryTimeTz:164, decodeInetBinary:191, + decodeBinaryArray:239, decodeBinaryComposite:327, + decodeHstoreBinary:16, decodeBinaryTsVector:699, decodeBinaryTsQuery:850 + (すべて decoding.nim) + 配列: pg_types/array.nim、レンジ: pg_types/ranges.nim、 + 複合/enum: pg_types/user_types.nim +``` + +--- + +## 3. データフロー図 + +### 3.1 送信パス (利用者パラメータ → サーバ) + +``` +利用者コード + │ sql"..." マクロ (pg_sql.nim:213) ── {expr} → $n + seq[PgParam] + │ pgParams マクロ (encoding.nim:599) + │ toPgParam* (encoding.nim:108〜) ── テキスト形式 PgParam{oid,format=0,value} + │ toPgBinaryParam* (encoding.nim:614〜) ── バイナリ形式 PgParam{format=1} + │ toPgParamInline* (encoding.nim:8〜) ── PgParamInline (小値は inlineBuf) + ▼ +query/exec (query.nim:177 / exec.nim:69) + │ checkReady (simple_query.nim:63) + │ lookupStmtCache / invalidateIfOidMismatch (cache.nim:31 / core.nim:220) + │ extractParams (core.nim:262) / flattenInline (core.nim:301, appendInlineParam:273) + ▼ +sendExtendedQuery / sendExtendedExec テンプレート (core.nim:322 / 395) + │ conn.sendBuf へ積込: + │ addParse (pg_protocol.nim:562) ── Parse: stmt名, SQL, param OID + │ addBind (pg_protocol.nim:579) ── Bind: param formats/values + result formats + │ addBindRaw (pg_protocol.nim:612) ── inline/raw データ用 + │ addDescribe(664) / addExecute(673) / addClose(682) / addSync(691) + │ (cache hit: Bind+Execute+Sync / miss: Parse+Describe+Bind+Execute+Sync) + ▼ +sendBufMsg (buffer_io.nim:562) または sendMsg (buffer_io.nim:544) + │ 失敗時 conn.state = csClosed + ▼ +chronos: conn.writer.write / asyncdispatch: socket.sendRawBytes (buffer_io.nim:159) + ▼ +socket → PostgreSQL サーバ +``` + +simple protocol: `encodeQuery` (pg_protocol.nim:550) → `sendMsg`。 +接続時: `encodeStartup` (pg_protocol.nim:476)、`encodeSSLRequest` (514)、 +`encodePassword` (524)、`encodeSASLInitialResponse` (534)、`encodeSASLResponse` (543)。 +その他: `encodeCancelRequest` (745)、`encodeCopyData` (753)、`encodeCopyDone` (774)、 +`encodeCopyFail` (778)、`encodeTerminate` (741)、`encodeStandbyStatusUpdate` (1362)。 + +### 3.2 受信パス (サーバ → 利用者) + +``` +PostgreSQL サーバ + ▼ +socket → fillRecvBuf (buffer_io.nim:183) → conn.recvBuf (seq[byte]) + ▼ +nextMessage (buffer_io.nim:276) → parseBackendMessage (pg_protocol.nim:1166) + ├─ DataRow → parseDataRowInto (pg_protocol.nim:1099) → RowData.buf/cellIndex + ├─ RowDescription → qr.fields / キャッシュ (core.nim:469-483) + ├─ CommandComplete→ qr.commandTag / CommandResult + ├─ Error → PgQueryError (types.nim:835) → ReadyForQuery 後に raise + ├─ Notification → notifyQueue → waitNotification / onNotify callback + └─ ParameterStatus→ conn.serverParams + ▼ +QueryResult{fields, data: RowData, rowCount, commandTag} (types.nim:371) + ▼ +rows/items (simple_query.nim:43/53) → initRow (pg_protocol.nim:310) → Row ビュー + ▼ +Row アクセサ getStr/getInt/... (accessors.nim) → decoding.nim で遅延デコード + ▼ +利用者コード +``` + +### 3.3 DSN → ConnConfig → connect + +``` +DSN 文字列 + ▼ +parseDsn (dsn.nim:708) + ├─ postgresql:// / postgres:// → parseUriDsn (dsn.nim:525) + └─ その他 → parseKeyValueDsn (dsn.nim:403) + ▼ +applyParam (dsn.nim:286) ※ sslrootcert/sslcert/sslkey はファイル読込 (§4 参照) + ▼ +ConnConfig (types.nim:109) + validateClientCertConfig (types.nim:742) + ▼ +connect(config) (lifecycle.nim:584) + → orderedHosts (554, lbhRandom 時は sysrand.urandom でシャッフル) + → attemptHostTimed (518, ホスト毎 connectTimeout) + → attemptHost (505) → matchesOrClose (481, target_session_attrs) + → connectToHost (128) +``` + +### 3.4 SSL ネゴシエーション + +``` +connectToHost (lifecycle.nim:274-275) + ▼ +negotiateSSL (ssl.nim:518) + ├─ validateClientCertConfig / validateDirectSslCompatible (ssl.nim:215) + ├─ sslnDirect (PG17+): 直ちに establishTls、ALPN "postgresql" 必須 + └─ sslnPostgres: encodeSSLRequest 送信 → 1 バイト応答 ('S'/'N') + ├─ pre-TLS 注入検出: socketHasPendingData (buffer_io.nim:684) / + │ extraBytesBuffered (ssl.nim:556-574) [CVE-2021-23214 系対策] + └─ 'S' → establishTls (ssl.nim:256) + ├─ chronos (BearSSL): + │ parseTrustAnchors (pg_bearssl.nim:125) ← sslRootCert PEM + │ TLSCertificate.init / TLSPrivateKey.init ← sslCert/sslKey + │ newTLSClientAsyncStream (TLS1.2 のみ) → handshake + │ installX509Capture (pg_bearssl.nim:103) → serverCertDer 取得 + └─ asyncdispatch (OpenSSL): + writeTempPem (ssl.nim:361) で PEM を一時ファイル化 (/dev/shm 優先) + SSL_CTX_load_verify_locations / use_certificate / PrivateKey + wrapConnectedSocket → driveTlsHandshake (ssl.nim:103) + verify-full: enforceVerifyFullIdentity (ssl.nim:180) + SSL_get_peer_certificate → i2d_X509 → conn.serverCertDer + 一時ファイルは removeTempPem (ssl.nim:352) で削除 +``` + +### 3.5 認証フロー + +``` +authLoop (lifecycle.nim:306-415) + ├─ bmkAuthenticationOk → break (scramStarted なら scramFinalVerified 必須) + ├─ bmkAuthenticationCleartext → enforceAuthAllowed → encodePassword + ├─ bmkAuthenticationMD5Password → md5AuthHash (pg_auth.nim:43) → encodePassword + ├─ bmkAuthenticationSASL → filterSaslByRequireAuth (lifecycle.nim:46) + │ → selectScramMechanism (lifecycle.nim:60) + │ (channel binding: computeTlsServerEndpoint + │ pg_auth.nim:322, serverCertDer 使用) + │ → scramClientFirstMessage (pg_auth.nim:64) + │ → encodeSASLInitialResponse + ├─ bmkAuthenticationSASLContinue → scramClientFinalMessage (pg_auth.nim:114) + │ (saslprep: pg_saslprep.nim / PBKDF2 同期、 + │ iteration 上限 effectiveMaxScramIterations) + │ → encodeSASLResponse + └─ bmkAuthenticationSASLFinal → scramVerifyServerFinal (pg_auth.nim:340) + (失敗→接続拒否。相互認証) + ※ パスワード/鍵素材は burnMem/burnStr で拭き取り (pg_auth.nim:10) +``` + +--- + +## 4. ファイルシステム / 外部リソースに触る箇所 + +ライブラリ自体は永続化層を持たない。クライアント側の FS 接触は SSL 証明書 +まわりのみ。 + +| 箇所 | ファイル:行 | 操作 | 備考 | +|---|---|---|---| +| `readPemFileParam` | async_postgres/pg_connection/dsn.nim:214 | **読込** | sslrootcert/sslcert/sslkey のパス指定をディスクから読込。`applyParam` の 342-350 から呼ばれる | +| `openRegularFile` | async_postgres/pg_connection/dsn.nim:186 | **読込** (posix.open/fstat) | 通常ファイル保証 + TOCTOU 緩和 (O_NONBLOCK→fstat→F_SETFL)。sslkey は group/world 権限ビットを拒否 (225-230) | +| `writeTempPem` | async_postgres/pg_connection/ssl.nim:361 | **書込+削除** | asyncdispatch+OpenSSL のみ。PEM 内容を 0600 一時ファイル化 (`createTempFile`、Linux では /dev/shm 優先: 366-376)。`removeTempPem` (352) で newContext 読込後ただちに削除 | +| `dirExists("/dev/shm")` | async_postgres/pg_connection/ssl.nim:368 | 参照 | 一時ファイル配置先判定 | +| `loadLibPattern` | async_postgres/pg_connection/ssl.nim:73-74 | **動的操作** | libssl/libcrypto の動的ロードとシンボル解決 (SSL_set1_host 等) | +| `urandom(8)` | async_postgres/pg_connection/lifecycle.nim:570 | **読込** (OS エントロピ) | `load_balance_hosts=random` のシャッフルシード (std/sysrand) | +| `randomBytes` | async_postgres/pg_auth.nim:78 | OS エントロピ | SCRAM クライアントノンセス (nimcrypto) | +| `loImport` / `loExport` | async_postgres/pg_largeobject.nim:209 / 218 | **サーバ側 FS** | `SELECT lo_import/lo_export` — ファイル I/O は PostgreSQL サーバホスト上で実行される。クライアント FS には触れない。サーバ側任意パス書込になり得るため呼び出し元の権限制御が前提 | +| ソケット I/O | lifecycle.nim:178-263、simple_query.nim:151-199 (cancel)、buffer_io.nim 全体 | ネットワーク | TCP/Unix ドメインソケット、DNS 解決 (resolveTAddress)、TCP keepalive/NODELAY (buffer_io.nim:743-780) | + +**該当なし**: DB ファイル書込、ローカル状態ファイル、キャッシュファイル、 +ログファイル出力 (診断は `warnStderr` types.nim:734 で stderr のみ)。 + +--- + +## 5. 未確認 / 部分確認の領域 + +- `pg_pool.nim` のメンテナンスループ / ヘルスチェック / 接続破棄ポリシーの + 内部実装 (公開 API の入口のみ確認。2310 行中の大半は未精査) +- `pg_client/pipeline.nim` の送信バッファ構築内部 (公開 API のみ確認) +- `pg_client/transaction.nim` のマクロ展開本体 (AST 構築ヘルパ群) +- `pg_types/user_types.nim` (複合型/enum の登録・デコード詳細) +- `pg_types/ranges.nim` のレンジ/マルチレンジ デコード本体 + (エンコード側 toPgParam/toPgBinaryParam の行は確認済み) +- `async_backend.nim` のバックエンド抽象内部 (wait/sleepAsync 等の実装詳細) +- `pg_bytes.nim` / `pg_errors.nim` / `pg_saslprep.nim` の内部アルゴリズム +- `htmdocs/`、`tests/`、`examples/` (本調査はライブラリ本体のみ) +- 各 Row アクセサの個別デコードロジックの正当性 (入口と委譲先は確認済み、 + 個々のパース処理の精査は decoding.nim の関数名・行の列挙に留まる) diff --git a/audit/map_deps_build.md b/audit/map_deps_build.md new file mode 100644 index 00000000..a154d47e --- /dev/null +++ b/audit/map_deps_build.md @@ -0,0 +1,131 @@ +# 監査: 外部依存とビルド/CI/リリースの健全性 (async_postgres) + +- 調査日: 2026-07-31 +- 対象リビジョン: HEAD c058f5e (main) +- 調査種別: 読み取り専用監査 + +--- + +## 1. 外部依存一覧 + +### 1.1 nimble 宣言済み (async_postgres.nimble) + +| 依存 | 制約 | 役割 | コード内の使用箇所 | +|---|---|---|---| +| nim | `>= 2.2.4` | 言語/コンパイラ | 全体 | +| nimcrypto | `>= 0.7.3` | SCRAM-SHA-256 (pbkdf2/sha256/hmac) + メモリ抹消 (`burnMem`) | `async_postgres/pg_auth.nim` (import pkg/nimcrypto, nimcrypto/pbkdf2, nimcrypto/utils), `pg_connection/lifecycle.nim` (ncutils.burnMem), `pg_saslprep.nim` (ncutils.burnMem), tests/test_auth.nim, tests/test_network_failure.nim | +| checksums | `>= 0.2.2` | MD5 認証ハッシュ (`getMD5`) | `async_postgres/pg_auth.nim:3` (import pkg/checksums/md5) — `md5AuthHash` のみ | +| unicodedb | `>= 0.13.2` | SASLprep の Unicode 性質テーブル | `async_postgres/pg_saslprep.nim:16` (pkg/unicodedb/properties) | +| normalize | `>= 0.9.0` | SASLprep の NFKC 正規化 | `async_postgres/pg_saslprep.nim:15` (pkg/normalize) | + +### 1.2 README 言及あり・nimble 未宣言 (chronos バックエンド向けオプション依存) + +| 依存 | README 制約 | 役割 | コード内の使用箇所 | +|---|---|---|---| +| chronos | `>= 4.4.0` | 代替 async バックエンド + TLS ストリーム | `async_postgres/async_backend.nim:22`, `pg_connection/{types,notify,buffer_io,ssl}.nim` (chronos/streams/tlsstream), `pg_bearssl.nim:8`, tests/test_pool.nim, tests/test_transaction_cancel.nim | +| nim-bearssl | `>= 0.2.11` | chronos バックエンドの TLS (BearSSL, TLS 1.2 のみ) | `async_postgres/pg_bearssl.nim:9` (bearssl/[x509,rsa,ec,ssl]), `pg_connection/{types,notify,ssl}.nim` (../pg_bearssl), tests/test_ssl.nim (bearssl/abi/bearssl_ssl) | + +### 1.3 所見 + +- **chronos / nim-bearssl が nimble に未宣言**。`-d:asyncBackend=chronos` 時の必須依存だが、 + nimble はこれを解決しない。CI は `nimble install chronos -y` で**バージョン固定なし**に + 手動インストール (test.yml:77)。bearssl は chronos の推移依存として入るだけで、こちらも + 固定なし。README の下限 (chronos 4.4.0 / bearssl 0.2.11) は文書のみで強制力がない。 + → chronos バックエンド利用者は破壊的変更/ALPN 非対応版を掴むリスク。 +- **全依存が `>=` の下限のみで上限なし**。nimcrypto/checksums/unicodedb/normalize いずれも + 破壊的変更 (メジャー/0.x の minor) を追跡するリスク。ロックファイル (nimble.lock 相当) は + リポジトリに存在しない。 +- **暗号依存の軽微な重複**: MD5 は nimcrypto にも実装があるが、コードは MD5 のみ + `checksums` を使用 (pg_auth.nim)。SHA-256/HMAC/PBKDF2 は nimcrypto。機能的重複は小さいが、 + 2 つの暗号系パッケージに依存が分裂している。 +- asyncdispatch バックエンドの TLS は標準庫の OpenSSL ラッパ (`-d:ssl`, pg_connection/ssl.nim) + で、外部依存ではない。 + +--- + +## 2. CI ワークフローの概要と網羅の穴 + +### 2.1 概要 + +| ワークフロー | トリガ | 内容 | 最終更新 (git log) | +|---|---|---|---| +| `.github/workflows/test.yml` | PR + push(main), パスフィルタ | Nim マトリクス **2.2.4 / stable / devel** (OS は ubuntu-latest のみ)。gen_certs.sh → docker compose で PG 起動 → chronos インストール → `nimble test` (asyncdispatch + chronos 両方) → examples を両バックエンドでコンパイル → doc 生成 | 2026-07-14 | +| `.github/workflows/docs.yml` | push(main), パスフィルタ | Nim stable で doc 生成 → GitHub Pages へ deploy-pages で公開 (gh-pages 相当) | 2026-03-08 | +| `.github/workflows/nph.yml` | PR, パスフィルタ | `nph` フォーマットチェック (fail:true) | 2026-03-08 | + +- `docker-compose.yml` (最終更新 2026-05-25): **postgres:18** のみ。`test/test/test` の固定 + 資格情報、ポート 15432:5432、SSL on (tests/certs の server.crt/key, ca.crt をマウント), + `wal_level=logical`。init スクリプト `tests/pg_init/10-physical-replication-hba.sh` が + pg_hba.conf に replication 行 (scram-sha-256) を追加。 +- dependabot / renovate 設定なし (.github 配下は workflows のみ)。 + +### 2.2 網羅の穴 + +- **OS が ubuntu-latest のみ**。matrix の os 次元は実質単一。macOS/Windows は未検証 + (README もプラットフォーム制約は明記せず)。 +- **PostgreSQL バージョンが 18 のみ**。README は Direct SSL に PG17+、sslnegotiation 等に + 言及するが、旧バージョン (PG13–17) 互換のマトリクスはない。 +- **chronos / bearssl がバージョン無固定** (上記 1.3)。CI が README の下限を保証しない。 +- **nph-action が `version: latest`** で未ピン → フォーマッタの挙動変化で CI が非再現に + なるリスク。 +- docs.yml / nph.yml は 2026-03-08 以降更新なし (古いが機能はしている)。 +- テストは両 async バックエンドを走る (nimble task test) — バックエンド網羅は十分。 + Nim バージョン網羅 (2.2.4/stable/devel) も十分。 + +--- + +## 3. リリース / semver 運用の状況 + +- タグ: **v0.1.0** (2026-03-27), **v0.2.0** (2026-04-27), **v0.3.0** (2026-05-28)。 + 月次ペースで minor バンプ。バージョンバンプは専用 PR ("Bump to 0.x.0")。 +- nimble の version は `0.3.0` で最新タグと一致。 +- **HEAD は v0.3.0 から 555 コミット先行** (約 2 ヶ月、2026-05-28 → 2026-07-29)。 + 大量の fix/refactor/test が未リリースで溜まっている。 +- **CHANGELOG / HISTORY / NEWS ファイルは存在しない**。破壊的変更の追跡手段は + git log / PR のみ。 +- 0.x 系のため semver 上 minor での破壊的変更は許容されるが、それを示す運用 + (CHANGELOG やマイグレーション注記) は確認できない。 +- 未追跡の `reviews/RELEASE_0.4.0_TODO.md` に 0.4.0 リリース前の要修正項目があり、 + 次リリースは計画中 (リポジトリ追跡外)。 + +--- + +## 4. リポジトリの不要物・機密の所見 + +### 4.1 async_postgres.out (938KB バイナリ) + +- ワークツリに存在 (938,816 bytes, 2026-07-23) するが、**git 未追跡**。 + `.gitignore` の `*.out` (line 34) で忽略済み。`git ls-files` / `git status` にも出ない。 + → リポジトリ汚染・履歴肥大のリスクはなし。ローカル残骸 (コンパイル出力) のみ。 + +### 4.2 `.,` ディレクトリ + +- ワークツリ直下に存在するが**空**。git 未追跡 (空ディレクトリは git が追跡しない)。 + コマンドのタイプミス (例: `mkdir .,` やパス誤り) で生じた残骸と推定。実害なし・追跡外。 + +### 4.3 tests/certs/ の鍵ファイル + +- `tests/certs/` は `.gitignore` (line 6) で忽略済み。追跡されているのは生成スクリプト + `tests/gen_certs.sh` のみ。 +- 鍵/証明書は CI・ローカルで gen_certs.sh が openssl により**その場で生成**する自己署名 + テスト用 (CN=Test CA / CN=localhost, SAN: localhost,127.0.0.1, wrong_ca も含む)。 + リポジトリに秘密鍵はコミットされていない。server.key は chmod 600 される。 + +### 4.4 ハードコード資格情報 + +- grep (password/secret/api_key/token/PRIVATE KEY) の命中は全て: + - テスト用固定値: docker-compose の `test/test/test`、テストコードの "pencil", "mypass", + "secret", "wrong_password" 等 (DSN/認証の単体・E2E フィクスチャ)。 + - PostgreSQL プロトコルのフィールド名 (`secretKey` = backend key data)。 +- **実在の秘密情報・API キー・秘密鍵のハードコードは検出なし**。 +- 未追跡の作業残骸: `reviews/`, `tests/review_rowdata.md`, `tests/test_ssl_coverage.md`, + `.claude/`, `htmdocs/`, `.audit/` (いずれも git 追跡外。機密は含まず)。 + +--- + +## 未確認の領域 + +- 各依存パッケージの上流 changelog との実際の互換性 (オフライン調査のため未確認)。 +- GitHub Actions の実際の実行結果/履歴 (リモート未アクセス)。 +- examples/・tests/ 全ファイルの精査 (依存 import と資格情報の grep のみ実施)。 +- `htmdocs/` 生成物の内容精査。 diff --git a/audit/map_modules.md b/audit/map_modules.md new file mode 100644 index 00000000..de54a9ca --- /dev/null +++ b/audit/map_modules.md @@ -0,0 +1,217 @@ +# モジュール分割と依存グラフ — async_postgres + +対象: `async_postgres.nim` (ルート) + `async_postgres/` 配下。計 **43 ソースモジュール / 26,516 行**。 +`tests/`, `examples/` は依存グラフの集計から除外(これらはライブラリの消費者であり、モジュールグラフの一部ではない)。 + +調査方法: +- 行数: `wc -l`(プロファイル済み数値と一致を確認)。 +- import: `rg '^\s*(import|from|include)\s'` で単一行importを全抽出。複数行import(`import` 単独行)は + `async_postgres.nim:131`, `pg_connection.nim:49`, `pg_client.nim:62` の3件(全てre-exportハブ)のみで、全文を読んで捕捉。 + `include` 文はソースに存在しない(コメント内の1一致 `pg_types/array.nim:21` のみ)。 +- 被参照数(fan-in): 「そのモジュールをimportしている**別ソースファイル数**」で集計。ハブとサブモジュールは別ノードとして扱う。 + `pg_bearssl` のimportは chronos バックエンド時のみ有効(条件付き)だが、import文の存在をもって1件と数える。 + +--- + +## 1. モジュール一覧表 + +### L0 — 基盤(内部依存なし / std・外部pkgのみ) + +| パス | 責務 | 行数 | 主要公開要素 | +|---|---|---|---| +| `async_postgres/async_backend.nim` | 非同期バックエンド抽象(asyncdispatch / chronos の切換え)。`Future`/`async`/`await` 等の統一エイリアス | 354 | macro `declareAsyncCallback*`; template `makeAsyncSinkByteCallback*`, `makeAsyncSeqByteCallback*`; proc `remainingDeadlineDuration*` | +| `async_postgres/pg_errors.nim` | 例外階層。全例外は `PgError` 派生(`PgConnectionError`/`PgStateError`/`PgTimeoutError`/`PgQueryError`/`PgProtocolError` 等) | 217 | type `ErrorField*`, `PgError*` 以下の例外ref型群(import無し・純粋な型定義リーフ) | +| `async_postgres/pg_saslprep.nim` | RFC 4013 SASLprep(SCRAM用パスワード正規化)。pkg/normalize, unicodedb を使用 | 156 | proc `saslprep*` | + +### L1 — バイト列・ワイヤプロトコル・認証 + +| パス | 責務 | 行数 | 主要公開要素 | +|---|---|---|---| +| `async_postgres/pg_bytes.nim` | ビッグエンディアンの低レベルバイト操作(encode/decode, bulk copy用)。`pg_errors` のみに依存し循環を断つ | 120 | template `writeBE16/32/64*`, `writeBytesAt*`, `appendBytes*`; func `toBe16/32/64*`, `fromBE16/32/64*`, `decodeFloat32/64BE*`; proc `readString*`, `readBytes*` | +| `async_postgres/pg_protocol.nim` | ワイヤプロトコルv3のメッセージ定義・エンコード/デコード。Frontend/Backendメッセージ、RowData/Row、DataRow解析、COPY/レプリケーション補助 | 1374 | type `FrontendMessageKind*`, `BackendMessageKind*`, `TransactionStatus*`, `FieldDescription*`, `BackendMessage*`, `RowData*`, `Row*`; proc `encodeStartup*`, `encodeQuery*`, `addParse/Bind/Describe/Execute/Sync*`, `parseBackendMessage*`, `parseDataRowInto*`, `buildResultFormats*`, `formatError*`, `encodeStandbyStatusUpdate*` 他多数 | +| `async_postgres/pg_auth.nim` | 認証(MD5, SCRAM-SHA-256/-PLUS)。channel binding (tls-server-end-point) | 359 | template `burnStr*`; proc `md5AuthHash*`, `scramClientFirstMessage*`, `scramClientFinalMessage*`, `scramVerifyServerFinal*`, `computeTlsServerEndpoint*` | + +### L2 — 型システム(pg_types/) + +| パス | 責務 | 行数 | 主要公開要素 | +|---|---|---|---| +| `async_postgres/pg_types/core.nim` | 中核PG型(PgNumeric/PgMoney/PgUuid/PgInterval/PgTime/PgInet/幾何型 等)と `PgParam`/`CommandResult`、テキストparse補助 | 1096 | type `PgUuid*`, `PgMoney*`, `PgNumeric*`, `PgInterval*`, `PgTime*`, `PgInet*`, `PgPoint*`…`PgCircle*`, `PgParam*`, `PgParamInline*`, `ResultFormat*`, `CommandResult*`; proc `parsePgNumeric*`, `parsePgMoney*`, `pgParseInt32*` 他(`export pg_errors`) | +| `async_postgres/pg_types/array.nim` | N次元配列 `PgArray[T]` の形状検証・構築 | 183 | type `PgArray*[T]`; proc `pgArray*`(多重定義), `validatePgArrayShape*`, `expectedElemCount*`, `validate*`, `isEmpty*`, `ndim*` | +| `async_postgres/pg_types/decoding.nim` | サーバ由来データのデコード(binary/text)。numeric, timestamp, inet, hstore, array, composite, tsvector/tsquery | 935 | proc `decodeNumericBinary*`, `decodeBinaryTimestamp*`, `decodeBinaryArray*`, `decodeBinaryComposite*`, `parseTimestampText*`, `parseIntervalText*`, `parseHstoreText*`, `decodeInetBinary*` 他(`export pg_bytes, array`) | +| `async_postgres/pg_types/encoding.nim` | パラメータエンコード。`toPgParam`/`toPgBinaryParam`/`toPgParamInline` の大量多重定義、配列/几何/JSON、ゼロアロケーション `addParseDirect`/`addBindDirect` | 1940 | proc `toPgParam*`(多数), `toPgBinaryParam*`(多数), `toPgParamInline*`, `encodeBinaryArray*`, `encodeNumericBinary*`, `coerceBinaryParam*`; macro `pgParams*`, `addParseDirect*`, `addBindDirect*`(`export pg_bytes, array`) | +| `async_postgres/pg_types/accessors.nim` | 行アクセサ。`Row.get*`/`getXxx`(int/float/text/uuid/几何/JSON…)、配列・ND配列取得、`decodePgArrayElement*`、nameAccessor機構 | 1860 | proc `getStr*`, `getInt*`, `getInt64*`, `getFloat*`, `getBool*`, `getJson*`, `getArrayND*`, `get*`(typedesc多重定義多数), `columnIndex*`; template `nameAccessor*`, `optAccessor*`; converter `toRow*` | +| `async_postgres/pg_types/user_types.nim` | ユーザ定義型(enum/composite/domain)のmacro生成と取得 | 514 | macro `pgEnum*`, `pgComposite*`, `pgDomain*`; proc `getEnum*`, `getComposite*`, `getDomain*`, `parseCompositeText*`, `encodeBinaryComposite*` | +| `async_postgres/pg_types/ranges.nim` | range / multirange 型。binary/textデコード、`toPgParam`/`toPgBinaryParam`、`get*` | 1223 | proc `rangeOf*`, `rangeFrom*`, `unboundedRange*`, `parseRangeText*`, `decodeInt4RangeBinary*`, `toMultirange*`, `toPgParam*`(PgRange/PgMultirange多数), `get*`(range系) | +| `async_postgres/pg_types.nim` | **re-exportハブ**。全pg_typesサブモジュールを `export` し、name-basedアクセサ(`accessorPair`/`arrayPair`/`rangeFamily` 等macro)を一括生成 | 160 | macro `accessorPair`, `arrayPair`, `elemOptPair`, `rangeFamily`(私有); proc `get*[T](Option[T])`; `export core, array, encoding, decoding, accessors, user_types, ranges` | + +### L2.5 — SSL補助 + +| パス | 責務 | 行数 | 主要公開要素 | +|---|---|---|---| +| `async_postgres/pg_bearssl.nim` | BearSSL X509処理(SCRAM-SHA-256-PLUS channel binding用)。葉証明書DER捕捉、信頼アンカparse。chronos時のみ使用 | 209 | type `X509CertCaptureContext*`, `TrustAnchorResult*`(chronos分岐内) | + +### L3 — 接続(pg_connection/) + +| パス | 責務 | 行数 | 主要公開要素 | +|---|---|---|---| +| `async_postgres/pg_connection/types.nim` | 接続層の共有土台。`PgConnection`/`ConnConfig`/状態enum、トレーシングデータ型・`PgTracer`、tracing template | 963 | type `PgConnState*`, `SslMode*`, `AuthMethod*`, `ConnConfig*`, `PgConnection*`, `QueryResult*`, `CopyResult*`, `CachedStmt*`, `PgTracer*`, `Notification*`; template `withConnTracing*`, `withTracing*`; proc `newPgQueryError*` 他 | +| `async_postgres/pg_connection/dsn.nim` | DSN解析(URI + keyword=value)。`initConnConfig`/`parseDsn`、各パラメータparse | 719 | proc `parseDsn*`, `initConnConfig*`, `parseUriDsn*`, `parseKeyValueDsn*`, `applyParam*`, `parseSslMode*`, `parseTargetSessionAttrs*` 他 | +| `async_postgres/pg_connection/buffer_io.nim` | トランスポートのバッファリングとメッセージI/O。recv/send、`nextMessage`/`recvMessage`、keepalive、通知dispatch、`closeTransport` | 780 | type `RecvWatch*`; proc `fillRecvBuf*`, `nextMessage*`, `recvMessage*`, `sendMsg*`, `closeTransport*`, `isConnected*`, `socketHasFin*`, `dispatchNotification*`, `getHosts*`; template `pumpUntilReady*` | +| `async_postgres/pg_connection/ssl.nim` | TLS/SSLネゴシエーション(postgres SSLRequest + direct SSL/ALPN)。chronos+BearSSL と asyncdispatch+OpenSSL | 612 | proc `negotiateSSL*`, `validateDirectSslCompatible*`, `sniName*` | +| `async_postgres/pg_connection/cache.nim` | サーバprepared statementのクライアント側LRUキャッシュ | 100 | proc `nextStmtName*`, `lookupStmtCache*`, `addStmtCache*`, `evictStmtCache*`, `removeStmtCache*`, `flushPendingStmtCloses*`, `clearStmtCache*` | +| `async_postgres/pg_connection/simple_query.nim` | Simple Queryプロトコル。`simpleQuery`/`simpleExec`/`ping`、`cancel`、`quoteIdentifier`、`checkSessionAttrs` | 447 | proc `simpleQuery*`, `simpleExec*`, `ping*`, `cancel*`, `invalidateOnTimeout*`, `quoteIdentifier*`, `checkSessionAttrs*`; type `QueryResult` のヘルパ群; template `awaitOrInvalidate*` | +| `async_postgres/pg_connection/lifecycle.nim` | 接続ライフサイクル。`connect`/`connectToHost`/`close`、ホストフェイルオーバ、SCRAM/require_auth補助 | 680 | proc `connectToHost*`, `enforceAuthAllowed*`, `filterSaslByRequireAuth*`, `selectScramMechanism*`(+ `connect`/`close` 等) | +| `async_postgres/pg_connection/notify.nim` | LISTEN/NOTIFY。`listen`/`unlisten`/`waitNotification`、バックグラウンドpump、`reconnectInPlace` | 429 | proc `listen*`, `unlisten*`, `waitNotification*`, `onNotify*`, `onListenError*`, `startListening*`, `stopListening*`, `listenPump*`, `reconnectInPlace*` | +| `async_postgres/pg_connection/type_lookup.nim` | 拡張型OID解決(`to_regtype`)。hstore/citext/vector 等のOIDを1往復で取得 | 107 | proc `lookupTypeOids*` | +| `async_postgres/pg_connection.nim` | **re-exportハブ**。全pg_connectionサブモジュール + `pg_errors` を `export` | 54 | `export pg_errors, types, dsn, buffer_io, ssl, cache, simple_query, lifecycle, notify, type_lookup` | + +### L4 — クライアント / クエリ実行(pg_client/) + +| パス | 責務 | 行数 | 主要公開要素 | +|---|---|---|---| +| `async_postgres/pg_client/core.nim` | client層の共有土台。トランザクションオプション、inline-paramエンコーダ、extended-queryのrecv-loop template | 622 | type `IsolationLevel*`, `AccessMode*`, `DeferrableMode*`, `TransactionOptions*`, `RetryOptions*`; proc `buildBeginSql*`, `isRetryableTxError*`, `backoffDelayMs*`, `extractParams*`, `flattenInline*`; template `sendExtendedQuery*`, `queryRecvLoop*`, `execRecvLoop*` | +| `async_postgres/pg_client/exec.nim` | `exec`(extended query・結果行無視・コマンドタグ返却) | 172 | proc `exec*`(seq[PgParam]/openArray[PgParamInline]), `notify*` | +| `async_postgres/pg_client/query.nim` | `query` と結果形状ヘルパ(`queryRow`/`queryValue`/`queryExists`/`queryColumn`)、行ストリーム `queryEach` | 459 | proc `query*`, `queryEach*`, `queryRow*`, `queryRowOpt*`, `queryValue*`, `queryValueOpt*`, `queryValueOrDefault*`, `queryExists*`, `queryColumn*` | +| `async_postgres/pg_client/prepared.nim` | 名前付きprepared statement(`prepare`/`execute`/`close`) | 192 | proc `prepare*`, `execute*`, `close*`; type `PreparedStatement` ヘルパ | +| `async_postgres/pg_client/copy.nim` | COPY IN/OUT(simple query)、ストリーム版 `copyInStream`/`copyOutStream` | 632 | proc `copyIn*`(多重定義), `copyInStream*`, `copyOut*`, `copyOutStream*` | +| `async_postgres/pg_client/transaction.nim` | トランザクション/セーブポイントmacro。`withTransaction` 系・deadline/retry変種 | 860 | macro `withTransaction*`, `withTransactionRetry*`, `withSavepoint*`, `withTransactionDeadline*`, `withTransactionRetryDeadline*`, `withSavepointDeadline*` | +| `async_postgres/pg_client/transaction_helpers.nim` | パイプライン単一文トランザクション(BEGIN+SQL+COMMITを1 Syncで) | 224 | proc `execInTransaction*`, `queryInTransaction*` | +| `async_postgres/pg_client/pipeline.nim` | パイプラインバッチ実行(`addExec`/`addQuery`、単一Sync `execute` とエラー分離 `executeIsolated`) | 726 | type `PipelineOp*`, `PipelineResult*`, `Pipeline*`, `IsolatedPipelineResults*`; proc `newPipeline*`, `addExec*`, `addQuery*`, `execute*`, `executeIsolated*`, `reset*` | +| `async_postgres/pg_client/cursor.nim` | サーバ側portalカーソル(`openCursor`/`fetchNext`/`close`/`withCursor`) | 322 | proc `openCursor*`, `fetchNext*`, `close*`; template `withCursor*`; type `Cursor` | +| `async_postgres/pg_client/direct.nim` | ゼロアロケーション `queryDirect`/`execDirect` コンパイル時macro | 646 | macro `queryDirect*`, `execDirect*` | +| `async_postgres/pg_client.nim` | **re-exportハブ**。全pg_clientサブモジュールを `export`(coreは安定公開面のみ選別re-export) | 79 | `export core.{IsolationLevel, AccessMode, …, backoffDelayMs}`; `export exec, query, prepared, copy, transaction, transaction_helpers, pipeline, cursor, direct` | + +### L5 — 高レベル機能 + +| パス | 責務 | 行数 | 主要公開要素 | +|---|---|---|---| +| `async_postgres/pg_pool.nim` | コネクションプール。ヘルスチェック、メンテ、acquire/release、プール経由query/exec、`withTransaction` 系macro | 2310 | type `PoolConfig*`, `PoolMetrics*`, `PooledConnHandle*`, `PgPool*`; proc `newPool*`, `acquire*`, `acquireHandle*`, `release*`, `exec*`, `query*`, `queryValue*`, `close*`; template `withConnection*`, `withPipeline*`; macro `withTransaction*`, `withTransactionRetry*`, `withTransactionDeadline*` | +| `async_postgres/pg_pool_cluster.nim` | 読み取りレプリカプールクラスタ。クエリルーティング、フォールバック | 395 | type `PgPoolCluster*`, `ReplicaFallback*`; proc `newPoolCluster*`, `readConnection*`, `writeConnection*`, `close*`; template `withReadConnection*`, `withWriteConnection*`; macro `withTransaction*` 系 | +| `async_postgres/pg_replication.nim` | 論理/物理レプリケーション。ストリーミング、pgoutputデコーダ、WAL/LSN | 1387 | type `Lsn*`, `ReplicationMessage*`, `XLogData*`, `PgOutputMessage*`, `RelationInfo*`, `InsertMessage*`…; proc `connectReplication*`, `startReplication*`, `stopReplication*`, `startPhysicalReplication*`, `parsePgOutputMessage*`, `identifySystem*`, `createReplicationSlot*`, `sendStandbyStatus*` | +| `async_postgres/pg_largeobject.nim` | Large Object API。ストリーミング読書、deadline変種 | 485 | proc `loSeek*`, `loTell*`, `loTruncate*`, `loImport*`, `loExport*`, `loReadAll*`, `loWriteAll*`, `loSize*`, `loReadStream*`, `loWriteStream*`; template `withLargeObject*` | +| `async_postgres/pg_advisory_lock.nim` | アドバイザリロック(session/transaction, exclusive/shared)。`withAdvisoryLock` 系macro | 689 | proc `advisoryLock*`, `advisoryTryLock*`, `advisoryUnlock*`, `advisoryLockShared*`, `advisoryLockXact*` 他; macro/template `withAdvisoryLock*`, `withAdvisoryLockShared*`, `withAdvisoryLockXact*` | +| `async_postgres/pg_sql.nim` | SQLヘルパ。`?`プレースホルダ変換、`sql"..."` リテラルmacro | 586 | type `SqlQuery*`; func `sqlParams*`; macro `sql*` | + +### L6 — エントリ + +| パス | 責務 | 行数 | 主要公開要素 | +|---|---|---|---| +| `async_postgres.nim` | **公開エントリ / 顶层re-exportハブ**。L0–L5の主要12モジュールを `export` | 139 | `export async_backend, pg_protocol, pg_auth, pg_types, pg_connection, pg_client, pg_pool, pg_pool_cluster, pg_largeobject, pg_advisory_lock, pg_sql, pg_replication` | + +--- + +## 2. 依存グラフ概要(グループ分け) + +依存は全て「上位 → 下位」の単方向。層を下るほどfan-inが高い。 + +``` +L6 async_postgres.nim + └─> L5 {pg_pool, pg_pool_cluster, pg_replication, pg_largeobject, pg_advisory_lock, pg_sql} + + L1..L4 のハブ (pg_protocol, pg_auth, pg_types, pg_connection, pg_client) + +L5 高レベル機能 + pg_pool -> async_backend, pg_protocol, pg_connection, pg_types, pg_client + pg_pool_cluster -> async_backend, pg_protocol, pg_connection, pg_types, pg_pool, pg_client + pg_replication -> async_backend, pg_protocol, pg_connection, pg_types + pg_largeobject -> async_backend, pg_types, pg_protocol, pg_connection, pg_client + pg_advisory_lock -> async_backend, pg_protocol, pg_types, pg_connection, pg_client + pg_sql -> async_backend, pg_types, pg_connection, pg_client, pg_pool + +L4 pg_client/* (全て core を基底) + core -> async_backend, pg_protocol, pg_connection, pg_types + exec/query/prepared/copy/transaction_helpers/pipeline/cursor/direct + -> ../[async_backend, pg_protocol, pg_connection, pg_types] + ./core + transaction -> ../[async_backend, pg_protocol, pg_connection] + ./core (pg_types は非import) + pg_client(ハブ) -> 全サブモジュールを re-export + +L3 pg_connection/* (全て types を基底) + types -> async_backend, pg_auth, pg_errors, pg_protocol, pg_types, pg_bearssl(chronos) + dsn -> async_backend, pg_errors, types + buffer_io -> async_backend, pg_errors, pg_protocol, types + cache -> pg_protocol, types + ssl -> async_backend, pg_errors, pg_protocol, pg_types, types, buffer_io, pg_bearssl(chronos) + simple_query -> async_backend, pg_errors, pg_protocol, pg_types, types, buffer_io + lifecycle -> async_backend, pg_errors, pg_protocol, pg_auth, types, buffer_io, ssl, simple_query, dsn + notify -> async_backend, pg_errors, pg_protocol, types, buffer_io, cache, simple_query, lifecycle, pg_bearssl(chronos) + type_lookup -> async_backend, pg_errors, pg_types, types, simple_query + pg_connection(ハブ) -> pg_errors + 全サブモジュールを re-export + +L2.5 pg_bearssl -> async_backend, pg_types (chronos時のみ接続層から参照される) + +L2 pg_types/* (全て core を基底、相互はDAG) + core -> pg_errors, pg_bytes (export pg_errors) + array -> core + decoding -> pg_bytes, core, array (export pg_bytes, array) + encoding -> pg_bytes, pg_protocol, core, array (export pg_bytes, array) + accessors -> pg_protocol, core, decoding, encoding + user_types -> pg_protocol, core, decoding, encoding, accessors + ranges -> pg_protocol, core, encoding, decoding, accessors + pg_types(ハブ) -> pg_protocol + 全サブモジュールを re-export + +L1 pg_protocol -> pg_bytes, pg_errors + pg_auth -> pg_errors, pg_saslprep (+ pkg/checksums, pkg/nimcrypto) + pg_bytes -> pg_errors + +L0 pg_errors / async_backend / pg_saslprep (内部依存なし) +``` + +--- + +## 3. 循環依存・レイヤ違反の所見 + +### 3.1 循環依存: **検出されず(グラフはDAG)** + +- 全43モジュールのimportを抽出し、各グループの基底モジュール(`pg_errors`, `pg_bytes`, `pg_protocol`, + `pg_types/core`, `pg_connection/types`, `pg_client/core`)が**同グループの他サブモジュールや上位層を + importしていない**ことを確認した。これにより各グループ内部は木状、グループ間も下位→上位の一方通行。 +- グループ横断の閉路も無し。特に: + - `pg_connection/types -> pg_types(ハブ) -> pg_types/* -> pg_protocol -> pg_bytes -> pg_errors` は一方向。 + - `pg_connection/* -> pg_bearssl -> pg_types` も一方向(`pg_types` 側は `pg_bearssl`/`pg_connection` をimportしない)。 + - `pg_sql -> pg_pool` があるが、`pg_pool` は `pg_sql` をimportしないため閉路にならない。 +- 検証の前提: 複数行importは3ハブのみで全文確認済み、`include` 文は無し。よって見落としの経路は無いと判断。 + +### 3.2 レイヤ違反: **検出されず** + +指定された典型例は共に発生していない: +- `pg_protocol` が `pg_client` をimport: **無し**。`pg_protocol.nim:3` は `import pg_bytes, pg_errors` のみ(下位のみ)。 +- `pg_types` が `pg_connection` をimport: **無し**。`pg_types/*` のimportは `pg_errors`, `pg_bytes`, `pg_protocol` と + 同グループ内のみ。`pg_types.nim:3-4` は `import pg_protocol` + `import pg_types/[…]`。 +- 下位層(L0/L1/L2)が上位層(L3接続/L4クライアント/L5高レベル)をimportする経路は確認されなかった。 + +### 3.3 特筆すべき結合(違反ではないが結合度が高い点) + +- `async_postgres/pg_bearssl.nim:5` — 低レベルSSL補助である `pg_bearssl` が **`pg_types`(フルハブ)をimport**。 + 実際には channel binding 用の型参照が主目的とみられるが、L2.5のモジュールがL2ハブ全体に依存するのは + 結合としてやや重い(chronosビルド時のみ有効)。 +- `async_postgres/pg_connection/types.nim:13` — 接続層の土台 `types` が `pg_auth`, `pg_types`, `pg_protocol` を + 一括import(963行の肥大基底モジュール)。同 `:17` で `pg_bearssl`(chronos条件付き)。 +- `async_postgres/pg_sql.nim:31` — SQLヘルパ `pg_sql` が `pg_pool`(L5)に依存。同層内の結合だが、 + 「SQLリテラルmacro」が「プール」を知る構造は責務の観点で要注目(循環は無し)。 +- `pg_client/transaction.nim:6` は `pg_client/*` で唯一 `pg_types` をimportしない(macroのみで完結)。 + +--- + +## 4. 被参照数(fan-in)上位10モジュール + +集計単位: そのモジュールをimportする**別ソースファイル数**(43ソース + ルートの範囲。tests/examples除外)。 + +| 順位 | モジュール | 被参照数 | 主な参照元 | +|---|---|---|---| +| 1 | `pg_protocol` | **28** | ほぼ全層(L1除く全client/conn/typesサブモジュール + 高レベル + ハブ) | +| 2 | `async_backend` | **26** | 全client/connサブモジュール + 高レベル + bearssl(cache, pg_types/*, pg_errors等は非参照) | +| 3 | `pg_types` (ハブ) | **21** | 全高レベル + 全client(core除く各impl) + conn/{types,ssl,simple_query,type_lookup} + bearssl | +| 4 | `pg_connection` (ハブ) | **17** | 全高レベル + 全clientサブモジュール + ルート | +| 5 | `pg_errors` | **13** | pg_protocol, pg_auth, pg_bytes, connハブ + conn/{types,dsn,buffer_io,ssl,simple_query,lifecycle,notify,type_lookup}, pg_types/core | +| 6 | `pg_client/core` | **10** | pg_clientハブ + exec/query/prepared/copy/transaction/transaction_helpers/pipeline/cursor/direct | +| 7 | `pg_connection/types` | **9** | pg_connectionハブ + dsn/buffer_io/ssl/cache/simple_query/lifecycle/notify/type_lookup | +| 8 | `pg_types/core` | **7** | pg_typesハブ + array/encoding/decoding/accessors/user_types/ranges | +| 9 | `pg_client` (ハブ) | **6** | ルート + pg_pool/pg_pool_cluster/pg_largeobject/pg_advisory_lock/pg_sql | +| 10 | `pg_connection/buffer_io` | **5** | pg_connectionハブ + ssl/simple_query/lifecycle/notify | + +(参考)次点(被参照数4): `pg_bytes`, `pg_types/encoding`, `pg_types/decoding`, `pg_connection/simple_query`。 + +### 結合の要点 +- **`pg_protocol` と `async_backend` が事実上の基盤**(fan-in 28/26)。これら2つの変更は全モジュールに波及する。 +- ハブ(`pg_types`/`pg_connection`/`pg_client`)は re-export 専用で実装を持たないため、ハブ経由の依存は + 実体のあるサブモジュール(`pg_types/core`, `pg_connection/types`, `pg_client/core`)へ集約される。 + 各グループの `core`/`types` が**真の結合ハブ**(fan-in 6–10)。 diff --git a/audit/map_tests_conventions.md b/audit/map_tests_conventions.md new file mode 100644 index 00000000..4e32d2d0 --- /dev/null +++ b/audit/map_tests_conventions.md @@ -0,0 +1,250 @@ +# テスト分布と規約マップ (async_postgres) + +調査種別: 読み取り専用監査。行数は `wc -l`。CI は `nimble test` で +`-d:asyncBackend=asyncdispatch` と `-d:asyncBackend=chronos` の両方を実行 +(async_postgres.nimble:16-18)。集約エントリは `tests/all_tests.nim`。 + +--- + +## 1. テストファイル一覧 + +凡例: 分類 = e2e(実PG必要) / unit-mock(`mock_pg_server.nim`) / unit-ownmock(独自モック) / unit-pure(サーバ不要)。 +`all_tests` = `tests/all_tests.nim` の import リストに含まれるか。 + +**結論: 37 個の `test_*.nim` は全て `all_tests.nim` に import されている。 +ファイルは存在するが CI で走らない孤立テストは無い。** +(`all_tests.nim` は 37 モジュールを import。ヘルパー `e2e_common.nim` / +`mock_pg_server.nim` / 集約自体の `all_tests.nim` はテスト本体ではない。) + +### 1a. e2e テスト(実 PostgreSQL 127.0.0.1:15432 必要、docker-compose.yml) + +`e2e_common.nim` 経由(13ファイル): + +| ファイル | 行数 | テスト対象モジュール | 分類 | all_tests | +|---|---|---|---|---| +| test_e2e_query.nim | 338 | pg_client/query | e2e | yes | +| test_e2e_types.nim | 828 | pg_types/core, user_types, type_lookup | e2e | yes | +| test_e2e_pool.nim | 832 | pg_pool | e2e | yes | +| test_e2e_connection.nim | 394 | pg_connection/lifecycle (connect/failover) | e2e | yes | +| test_e2e_arrays.nim | 1522 | pg_types/array | e2e | yes | +| test_e2e_listen.nim | 1293 | pg_connection/notify (LISTEN/NOTIFY) | e2e | yes | +| test_abandonment_e2e.nim | 250 | pg_pool (acquire放棄) | e2e | yes | +| test_e2e_misc.nim | 512 | pg_client その他 API | e2e | yes | +| test_e2e_cursor.nim | 973 | pg_client/cursor | e2e | yes | +| test_cancel_e2e.nim | 211 | cancel (simple_query), PgQueryError 57014 | e2e | yes | +| test_e2e_convenience.nim | 2282 | pg_client query 簡易API群 / pipeline | e2e | yes | +| test_e2e_copy.nim | 1243 | pg_client/copy | e2e | yes | +| test_e2e_transaction.nim | 3537 | pg_client/transaction + transaction_helpers | e2e | yes | + +`e2e_common.nim` を使わず独自に実 PG 接続を定義(3ファイル、規約上の不整合): + +| ファイル | 行数 | テスト対象モジュール | 分類 | all_tests | +|---|---|---|---|---| +| test_advisory_lock.nim | 1069 | pg_advisory_lock + pg_pool | e2e(独自config) | yes | +| test_largeobject.nim | 767 | pg_largeobject | e2e(独自config) | yes | +| test_tracing.nim | 1614 | PgTracer フック全般 (pool/client) | e2e(独自config) | yes | + +> 上記3ファイルは `e2e_common.nim` の `plainConfig()` を使わず、同一内容の +> `PgHost/PgPort=15432/plainConfig()` をローカル再定義している +> (test_advisory_lock.nim:10-25, test_largeobject.nim:8-23, test_tracing.nim:10-25)。 +> 機能的には e2e(実PG依存)だが共有ヘルパー未使用。 + +### 1b. ユニットテスト(モック `mock_pg_server.nim` 使用、9ファイル) + +| ファイル | 行数 | テスト対象モジュール | 分類 | all_tests | +|---|---|---|---|---| +| test_pool.nim | 3639 | pg_pool (最大テスト) | unit-mock | yes | +| test_pool_cluster.nim | 738 | pg_pool_cluster | unit-mock | yes | +| test_network_failure.nim | 648 | pg_connection/lifecycle + buffer_io 障害経路 | unit-mock | yes | +| test_physical_replication.nim | 579 | pg_replication (物理) | unit-mock | yes | +| test_listen_reconnect.nim | 567 | pg_connection/notify 再接続 | unit-mock | yes | +| test_fill_recvbuf.nim | 167 | pg_connection/buffer_io (fillRecvBuf) | unit-mock | yes | +| test_transaction_cancel.nim | 189 | transaction cancel/timeout | unit-mock | yes | +| test_session_attrs.nim | 390 | pg_connection/simple_query (checkSessionAttrs) | unit-mock | yes | +| test_replication_keepalive.nim | 731 | pg_replication keepalive | unit-mock | yes | + +> test_pool.nim の `15432` 出現は DSN 解析テストのフィクスチャ文字列 +> (test_pool.nim:81,91) であり、実PG接続ではない。接続テストはモック使用。 + +### 1c. ユニットテスト(独自モック、1ファイル) + +| ファイル | 行数 | テスト対象モジュール | 分類 | all_tests | +|---|---|---|---|---| +| test_ssl.nim | 1706 | pg_connection/ssl + pg_bearssl | unit-ownmock | yes | + +> test_ssl.nim は `mock_pg_server.nim` を使わず、TLS ハンドシェイク用の +> `startMockServer` をファイル内で独自定義 (test_ssl.nim:33,66)。 +> chronos(BearSSL) / asyncdispatch(OpenSSL, `-d:ssl`) の両分岐を持つ。 + +### 1d. ユニットテスト(サーバ不要・純粋ロジック、11ファイル) + +| ファイル | 行数 | テスト対象モジュール | 分類 | all_tests | +|---|---|---|---|---| +| test_types.nim | 9107 | pg_types (encoding/decoding/ranges/accessors/core) | unit-pure | yes | +| test_protocol.nim | 1400 | pg_protocol + pg_bytes + pg_errors アクセサ | unit-pure | yes | +| test_dsn.nim | 1144 | pg_connection/dsn | unit-pure | yes | +| test_replication.nim | 783 | pg_replication (LSN, pgoutput デコード) | unit-pure | yes | +| test_protocol_fuzz.nim | 752 | pg_protocol (malformed/fuzz) | unit-pure | yes | +| test_auth.nim | 547 | pg_auth (MD5/SCRAM 暗号) | unit-pure | yes | +| test_sql.nim | 356 | pg_sql (sql マクロ/プレースホルダ) | unit-pure | yes | +| test_rowdata.nim | 339 | pg_types/decoding (行データ) | unit-pure | yes | +| test_saslprep.nim | 167 | pg_saslprep | unit-pure | yes | +| test_keepalive.nim | 98 | pg_connection (configureKeepalive sockopt) | unit-pure | yes | +| test_async_backend.nim | 36 | async_backend (makeAsyncSeqByteCallback) | unit-pure | yes | + +### 1e. ヘルパー(テスト本体ではない) + +| ファイル | 行数 | 役割 | +|---|---|---| +| all_tests.nim | 12 | 集約エントリ。37 テストを import | +| e2e_common.nim | 41 | e2e 用共有定数・config (PgHost=127.0.0.1, PgPort=15432) | +| mock_pg_server.nim | 356 | インプロセス・ワイヤプロトコルモック。バックエンド非依存 | + +テスト合計: 42,157 行(ヘルパー・集約含む、`wc -l tests/*.nim`)。 + +--- + +## 2. テストが無い・薄い主要モジュール + +ソース合計: 26,377 行(`async_postgres/` 配下、`wc -l`。ルート `async_postgres.nim` は別途)。 + +### 2a. タスクで名指しされたモジュール → 実測では全て厚くカバー済み + +| ソースモジュール | ソース行数 | 対応テスト | 評価 | +|---|---|---|---| +| pg_pool.nim | 2310 | test_pool(3639) + test_e2e_pool(832) + test_abandonment_e2e(250) + test_advisory_lock の pool 部 | 厚(mock+e2e、エラーパス密集) | +| pg_replication.nim | 1387 | test_replication(783) + test_physical_replication(579) + test_replication_keepalive(731) = 2093 | 厚(unit+mock) | +| pg_connection/ssl.nim | 612 | test_ssl(1706、独自TLSモック) | 厚(except 52、エラーパス密集) | +| pg_auth.nim | 359 | test_auth(547、暗号ユニット) | カバー済 | + +### 2b. 実際にテストが無い/薄いモジュール + +| ソースモジュール | ソース行数 | 状況 | +|---|---|---| +| async_backend.nim | 354 | 専用テストは test_async_backend.nim のみ(36行、`makeAsyncSeqByteCallback` の回帰に限定)。`wait`/`sleepMsAsync`/`cancelTimer`/`registerFdReader`/`scheduleSoon` と chronos/asyncdispatch 分岐そのものの専用ユニットテストは無し(全 async テストが間接行使)。**薄い** | +| pg_errors.nim | 217 | 専用テストファイル無し。アクセサ/述語(sqlState, constraintName, isUniqueViolation, isQueryCanceled)は test_protocol.nim:821-935 と e2e(test_cancel_e2e, test_e2e_transaction, test_e2e_misc, test_e2e_query)で行使。`parsePosition` の_overflow ガード (pg_errors.nim:168-178)、`where`/`internalQuery`/`internalPosition`、`isIntegrityConstraintViolation` 等は部分カバー。**専用テスト無し** | +| pg_bearssl.nim | 209 | test_ssl.nim の chronos 分岐のみが行使。asyncdispatch CI レグでは未実行(バックエンド依存)。**部分/依存** | +| pg_connection/type_lookup.nim | 107 | 専用テスト無し。e2e のユーザー定義型(test_e2e_types)経由の間接行使のみ | +| pg_connection/cache.nim | 100 | 専用テスト無し。e2e の prepared statement 経由の間接行使のみ | +| pg_bytes.nim | 120 | 専用ファイル無し。ただしバイトヘルパーは test_protocol.nim でテスト済(encodeInt16/32 等) | +| pg_client.nim / pg_connection.nim / pg_types.nim | 79 / 54 / 160 | 再エクスポートハブ(ロジック無し、テスト対象外は妥当) | + +--- + +## 3. エラーパステストの傾向 + +計測: `expect(` / `except ` / `try:` の出現数 + エラー系キーワード +(malformed/truncat/invalid/timeout/cancel/disconnect/overflow/dropped/premature/stall/corrupt/out of range) のヒット数。 + +### エラーパス重視(malformed input・例外・キャンセル・タイムアウトを積極テスト) +- test_pool.nim — except 65 / キーワード 317。acquire タイムアウト、プールクローズ、二重 release、接続切断など網羅。 +- test_network_failure.nim — except 44。障害経路専用(切断・malformed・ストール)。 +- test_ssl.nim — except 52 / try 58。ハンドシェイク失敗・証明書検証失敗。 +- test_protocol.nim — expect 41。malformed メッセージ解析。 +- test_protocol_fuzz.nim — except 17。ファジングで malformed input 耐性。 +- test_replication.nim — expect 33。不正 LSN / pgoutput デコード例外。 +- test_types.nim — expect 31 / except 54。型デコードの不正入力。 +- test_e2e_transaction.nim — except 44。デッドロック(40P01)/unique違反(23505)/cancel。 +- test_session_attrs.nim — except 15。test_e2e_listen.nim(19)/test_e2e_copy.nim(23)/test_e2e_convenience.nim(19) も多め。 +- test_dsn.nim — `expect` は 0 だがキーワード 49。不正 DSN を `check`/`expect` で多数検証(invalid input 重視)。 + +### happy path 中心 +- test_async_backend.nim — 36行、回帰特化(expect ValueError 1 のみ)。 +- test_sql.nim — except 0 / キーワード 0。SQL 生成の正常系。 +- test_e2e_types.nim — except 0 / キーワード 0。型ラウンドトリップ正常系。 +- test_rowdata.nim / test_saslprep.nim / test_keepalive.nim / test_e2e_arrays.nim / test_e2e_query.nim — 正常系中心。 +- test_auth.nim — except 0 だがキーワード 14(暗号の境界・異入力テストは `check` ベース)。 + +総傾向: **プロトコル/型/プール/ネットワーク/SSL の各レイヤはエラーパス(malformed・例外・キャンセル・タイムアウト)を厚くテスト**。 +一方 **SQL 生成・型ラウンドトリップ・sockopt・async バックエンド抽象は happy path 中心**。 +e2e 系はテストにより差が大きく、transaction/listen/copy/convenience はエラーパス厚め、types/arrays/query/connection は正常系中心。 + +--- + +## 4. 規約 + +### 4a. 明文規約の有無 +- **AGENTS.md: 無し**(リポジトリ内に存在しない。`**/AGENTS.md` glob 0件)。 +- **CLAUDE.md: 無し**(`**/CLAUDE.md` glob 0件)。 +- `.claude/` の中身は `settings.local.json`(Bash/Read の権限許可リストのみ)と `worktrees/`。規約文書は無し。 +- 利用者向け契約は README.md に記載(再接続ポリシー README.md:109-113、async バックエンド README.md:115-、エラー契約は pg_errors.nim モジュール doc)。 +- `reviews/` に 15 ファイル(個別レビュー 14 件 + `RELEASE_0.4.0_TODO.md`): + buffer_io, pg_advisory_lock, pg_copy, pg_pool, pg_protocol, pg_replication, + rest, review_decoding, review_dsn, review_encoding, REVIEW_largeobject, + review_pg_sql, REVIEW_ranges, review-transaction, RELEASE_0.4.0_TODO。 + +### 4b. lint / format 設定(nph) +- `.github/workflows/nph.yml`: `arnetheduck/nph-action@v1`。PR の `async_postgres.nim*` / + `async_postgres/**` / `tests/**` 変更時に起動。`version: latest`, `options: "./"`, + `fail: true`, `suggest: true`。→ **nph = Nim formatter。CI でフォーマットを強制**。 +- **nph の設定ファイルはリポジトリに無い**(`.nph`/`nph.json`/`nim.cfg`/`.editorconfig` 全て無し、glob/find 0件)。→ **nph デフォルトスタイル**を適用。 +- 補足: `.github/workflows/test.yml` は Nim 2.2.4/stable/devel マトリクス、docker-compose で実 PG 起動、`tests/gen_certs.sh` で証明書生成、`nimble test`(両バックエンド)、examples を両バックエンドでコンパイル、`nim doc` 生成。 + +### 4c. 帰納した暗黙の慣習(観測パスつき) + +**エラー型の使い方(例外階層)** — `async_postgres/pg_errors.nim` +- 全例外は `PgError`(CatchableError 派生) の単一階層 (pg_errors.nim:34)。呼び出し側は + `except PgError` 一節で全 pg 固有失敗を捕捉可能。 +- `PgProtocolError` は `PgConnectionError` のサブタイプ (pg_errors.nim:50) — プロトコル違反は + 接続を道連れに teardown するため。 +- `PgTimeoutError` も `PgConnectionError` のサブタイプ (pg_errors.nim:84) — タイムアウトは + CancelRequest を送り接続を csClosed にするため、再接続ループに捕捉させる意図。 +- `PgStateError` は意図的に `PgConnectionError` の**兄弟**(サブタイプではない, pg_errors.nim:57)— + 並行使用などのプログラミングエラーで、再接続が無意味なため reconnect ループから除外。 +- 非推奨エイリアスは `{.deprecated.}` で維持 (pg_errors.nim:54 `ProtocolError`)。 +- SQLSTATE 定数 `SqlState*` (pg_errors.nim:116-125) と述語 `is*Violation`/`isQueryCanceled` + (pg_errors.nim:191-217)、`PgQueryError` フィールドアクセサ (pg_errors.nim:136-187)。 +- 設計意図をモジュール先頭 doc で詳述 (pg_errors.nim:1-26)。 + +**async パターン** +- `{.async.}` proc を使用。バックエンド抽象は `async_backend.nim` に集約。 +- `hasChronos` / `hasAsyncDispatch` / `hasTls` の const ガード (async_backend.nim:11-19) で + `when hasChronos: ... elif hasAsyncDispatch: ...` 分岐(async_backend.nim:21,78)。 + 両バックエンドで同一 API 表面(`wait`/`sleepMsAsync`/`cancelTimer`/`registerFdReader`/`scheduleSoon`)を提供。 +- コールバック生成は `declareAsyncCallback` マクロ(tests/test_async_backend.nim:10)。 +- モック/テストもバックエンド非依存を維持(mock_pg_server.nim:9-11, test_ssl.nim の両分岐)。 + +**doc コメントのスタイル** +- `##` RST 形式。モジュール先頭に設計意図をまとめる(pg_errors.nim:1-26, pg_connection.nim:1-46, + async_backend.nim:1-5, mock_pg_server.nim:1-11, e2e_common.nim:1-2)。 +- 型・proc・フィールドに `##` doc。コード参照は二重バッククォート `` ``PgError`` ``。 +- フィールド inline doc(`tracer*: PgTracer ## Optional tracer ...` pg_pool.nim:49, types.nim:182)。 + +**命名規則** +- ファイル: snake_case + `pg_` プレフィックス(pg_pool.nim, pg_connection/ssl.nim)。 +- 型: PascalCase(PgConnection, ConnConfig, TraceContext, PgTracer)。 +- proc/フィールド: camelCase(advisoryLock, fillRecvBuf, onQueryStart)。 +- enum 値: 種別プレフィックス付き camelCase(sslDisable, csReady, tcdIn, skQuery, ckTxRollback, tcsTlsReader)。 +- 定数: PascalCase(SqlStateUniqueViolation pg_errors.nim:118)。 + +**モジュール構成** +- 薄く再エクスポートするハブ(pg_connection.nim:48-54, pg_client.nim, pg_types.nim)+ + 実装はサブディレクトリ(pg_connection/, pg_client/, pg_types/)に分割。 + +**tracing フックの使い方** — `async_postgres/pg_connection/types.nim` +- `PgTracer` は任意コールバックの `ref object` (types.nim:608)。nil コールバックはゼロオーバーヘッドでスキップ。 +- Start フックは `TraceContext`(=RootRef, types.nim:396) を返し、対応する End フックへ相関のために渡す。 +- 全フックは `{.gcsafe, raises: [].}`(types.nim:615-679)。 +- 支援テンプレート `withConnTracing`(types.nim:923) / `withTracing`(types.nim:944) が body を + try/except で包み、例外時は End フックに `err` を渡して再送出。 +- 飲み込まれるエラーの可視化用 advisory フック(onPoolCloseError, onTransportCloseError, + onLeakedSessionLocks, onCleanupSkipped, onPoolDoubleRelease)— pg_pool.nim:255-260,717-741。 +- tracer は `ConnConfig.tracer`(types.nim:182) / `PoolConfig.tracer`(pg_pool.nim:49) で注入。 + +**テストの慣習** +- `std/unittest` の suite/test。async テストは `proc t() {.async.} = ...; waitFor t()` でラップ。 +- 私有ヘルパー到達には `import ... {.all.}` + `privateAccess`(test_advisory_lock.nim:3-8, test_replication.nim:4-7)。 +- e2e 共有ヘルパー `e2e_common.nim`、モック `mock_pg_server.nim`。 + ただし test_advisory_lock / test_largeobject / test_tracing は e2e_common を使わず config をローカル再定義(不整合、§1a 参照)。 +- test_ssl / test_replication はモック/メッセージビルダーをファイル内で独自定義(test_ssl.nim:20-23, test_replication.nim:9-13)。 + +--- + +## 5. 未確認事項 +- 各テストの「テスト対象モジュール」は import 行・ヘルパー使用・冒頭コードからの推定。 + 全テスト本文を精読した網羅的マッピングではない(大規模ファイル test_types 9107行、 + test_e2e_transaction 3537行、test_pool 3639行 は冒頭と計測中心)。 +- pg_bearssl.nim の asyncdispatch レグ未実行は CI 設定からの推定。実 CI ログは未確認。 +- examples/(14サンプル)の内容精査は未実施(test.yml で両バックエンドコンパイルされる事実は確認)。 +- `reviews/` 各レビューの中身(個別指摘)は本タスク範囲外として未精読(存在と一覧のみ確認)。 +- nph の実際のフォーマット結果(差分)は未検証。設定ファイル不在=デフォルト、という事実のみ。 diff --git a/audit/profile.md b/audit/profile.md new file mode 100644 index 00000000..165e0583 --- /dev/null +++ b/audit/profile.md @@ -0,0 +1,48 @@ +# 対象プロファイル + +## 種別と根拠 +- **種別: ライブラリ / SDK** + - 根拠: `async_postgres.nimble` (version 0.3.0, license MIT) でパッケージ定義。`nimble install async_postgres` で配布 (README.md:78)。`examples/` に14の実行サンプル。`async_postgres.nim` が公開エントリで多数のモジュールを `export`。 + - アプリケーション/CLI のエントリポイント(サーバ起動・bin)は無い。 + +## 主要言語・バージョン・ランタイム +- 言語: Nim (requires `nim >= 2.2.4`) +- 非同期バックエンド: asyncdispatch (既定) または chronos (`-d:asyncBackend=chronos`)。`async_backend.nim` が抽象化層。 +- SSL: asyncdispatch=OpenSSL (`-d:ssl`)、chronos=BearSSL (TLS 1.2 のみ) +- CI: GitHub Actions、Nim 2.2.4 / stable / devel のマトリクス、docker-compose の実 PostgreSQL で e2e。 + +## 公開パッケージ名・バージョン +- `async_postgres` v0.3.0 (nimble)。次の 0.4.0 リリースに向けた TODO が `reviews/RELEASE_0.4.0_TODO.md`。 + +## 規模 +- ソース (async_postgres/ 配下 + async_postgres.nim): 41ファイル、約 26,516 行 +- テスト (tests/): 40+ ファイル +- 総コミット 556、コントリビュータ 3、`fix` コミット 138 件(直近にセキュリティ強化・malformed input 拒否が集中) + +## 想定される利用者 +- 外部の Nim 開発者(nimble 公開)。PostgreSQL に接続するアプリケーション/サービス作者。 +- 利用者はライブラリの型・エラー契約・並行性保証に依存する → 付録 C(ライブラリ固有観点)を適用する。 + +## 信頼境界の一覧(外部入力がシステムに入る全経路) +1. **PostgreSQL サーバからのワイヤプロトコル入力**(最主軸) + - `pg_protocol.nim` (メッセージ解析: DataRow/ErrorResponse/Authentication*/ParameterStatus/NotificationResponse/CopyData/LogicalReplication messages) + - `pg_connection/buffer_io.nim` (recv バッファリング、`nextMessage`/`recvMessage`) + - 脅威モデル: 悪意/Rogue サーバ、MITM。サーバ送信データは信頼できない。 +2. **型デコード(サーバ由来データのバイナリ/テキスト解析)** + - `pg_types/decoding.nim`, `pg_types/ranges.nim`, `pg_types/array.nim`, `pg_types/core.nim` + - 過去の fix の大半がここ(malformed input 拒否、int32 長ラップガード)。 +3. **DSN / 接続文字列**(利用者入力) + - `pg_connection/dsn.nim` (URI + keyword=value)。`sslcert`/`sslkey` はディスクからファイル読込 → パス操作・権限チェックが絡む。 +4. **認証ハンドシェイク** + - `pg_auth.nim` (MD5, SCRAM-SHA-256/-PLUS), `pg_saslprep.nim`, channel binding (tls-server-end-point) +5. **SSL/TLS ネゴシエーション** + - `pg_connection/ssl.nim`, `pg_bearssl.nim` (証明書検証、ALPN) +6. **SQL プレースホルダ展開**(コンパイル時マクロ) + - `pg_sql.nim` (`sql""` マクロ、`?` プレースホルダ) — 注入耐性が契約。 +7. **COPY / Large Object / Replication ストリーム** + - `pg_client/copy.nim`, `pg_largeobject.nim`, `pg_replication.nim` (pgoutput デコーダ、WAL) + +## 既存監査/レビュー状態 +- `reviews/` に14ファイルの個別レビュー + `RELEASE_0.4.0_TODO.md`(0.4.0 向け要修正トラッカー)。 +- 既存レビュー対象: buffer_io, advisory_lock, copy, pool, protocol, replication, decoding, dsn, encoding, largeobject, pg_sql, ranges, transaction。 +- 本監査はこれらを**文脈として参照**するが、既存問題こそ調査対象のため再調査する。重複排除は横断分析で行う。 diff --git a/audit/queue.md b/audit/queue.md new file mode 100644 index 00000000..9d91f374 --- /dev/null +++ b/audit/queue.md @@ -0,0 +1,35 @@ +# 調査キュー(リスク優先順位) + +優先度シグナル: 変更頻度(ホットスポット)× 信頼境界 × fan-in(影響半径)× テスト空白 × fixコミット集中。 + +> 完了履歴: Tier1 12 モジュール / Tier2 18 グループは調査完了(所見は `.audit/findings/`・`systemic.md`・`report.md` 参照)。 +> S1 残存 2 箇所(advisory_lock / largeobject の Defect 漏れ)は対処済み。 +> Tier3 残部は 2026-08-13 に調査完了。監査範囲 100%。 +> 残存所見の修正は継続中: S5 は全件対処済み(CopyDone race は 2026-08-14 対処。実サーバでは発生しないことを実証しつつ防御的硬化を追加)。 + +## Tier 1(深掘り:付録D全観点、関数レベル) + +## async_postgres/pg_types/core.nim +- Tier: 1 +- リスク根拠: 型システム基底、fan-in 7。OID/型判定/バイナリ安全テーブル。1096行。 +- 状態: 完了(所見は encoding.md / decoding.md / sql_accessors.md / transaction.md に分散記録。専用ファイル無し) +- 所見ファイル: .audit/findings/encoding.md ほか + +## Tier 3(機械的スキャンのみ) + +## ハブ群 (async_postgres.nim, pg_client.nim, pg_connection.nim, pg_types.nim) — Tier 3 — re-export 専用 — 完了 +## async_postgres/pg_connection/cache.nim + type_lookup.nim — Tier 3 — 小規模、e2e間接のみ — 完了 +## async_postgres/pg_client/transaction_helpers.nim — Tier 3 — 224行 — 完了 +## examples/ — Tier 3 — 14サンプル、現API一致性(付録C#8) — 完了(mechanical.md シグナル7、不一致 0 件) +## tests/ 全体 — Tier 3 — 機械的シグナル(巨大ファイル、TODO分布) — 完了(mechanical.md シグナル1/5/2 ほか) + +### 完了時所見(2026-08-13): ハブ群 + cache/type_lookup + transaction_helpers は欠陥なし。 +サブモジュールハブ単独 import 時の基盤型(BackendMessage/Row/PgConnection/QueryResult 等)名前空間の +不完全性 1 件(Low、`.audit/findings/tier3_hubs_remaining.md`)のみ。 + +## 機械的スキャン対象(Tier 3 横断) +- TODO/FIXME/HACK/XXX の分布と最古 +- 抑制コメント({.push raises.} 違反、pragma)の分布 +- 極大ファイル/関数 +- ハードコード資格情報/URL/パス(→ 実秘密は検出なし、確認済) +- コピペ重複ブロック diff --git a/audit/report.md b/audit/report.md new file mode 100644 index 00000000..e812712b --- /dev/null +++ b/audit/report.md @@ -0,0 +1,130 @@ +# コードベース監査報告 + +## 対象 +- **種別**: ライブラリ / SDK(`async_postgres.nimble` v0.3.0・MIT、nimble 公開、`examples/` 14件、`async_postgres.nim` が公開ハブ) +- **規模**: ソース41ファイル+ハブ 約26,516行 / テスト42,157行 / 主要言語 Nim(>= 2.2.4)、asyncdispatch(既定)/ chronos 両対応 +- **監査範囲**: Tier1 **12**モジュール(深掘り・全観点) / Tier2 **18**(限定:セキュリティ・公開API・依存) / Tier3 **残部**(機械的スキャン)— **100% 完了(2026-08-13)** +- **未調査領域**: raises pragma の実コンパイル不一致検証、各 e2e テストの全文精読(対象モジュールとの突合は import ベース)、GitHub Actions の実際の実行履歴(リモート未アクセス)、htmdocs/ 内容 + +## 総評 +成熟度が高く、規律の行き届いたコードベースである。依存グラフは清潔(循環依存・レイヤ違反ゼロ)、 +SQL 注入耐性・MITM 防御・SCRAM 実装はいずれも実測で堅牢、int32 長ラップガードは encoder に全適用、 +42K行のテストが両バックエンドで走る。138件の fix コミットが示す通り、malformed input 拒否の硬化が +継続的に進んでいる。asyncdispatch/chronos の抽象非対称は systemic に解消済み(キャンセル欠如を +吸収する finally:await パターンを全経路で規律化)。テキスト/バイナリ二重実装の契約乖離 +(**沈黙のデータ損傷**)も全 4 箇所を対処済み。 +残る計画的対応は、基盤層のテスト空白、依存固定/CHANGELOG/リリース運用の 2 系統。 + +--- + +## 計画的対応 + +### 5. テストの構造的空白の解消(S6) +- **重大度**: Medium(構造) +- **出現**: async_backend 専用テスト **36行**(fan-in 26)/実 TLS 統合テスト未カバー +- **影響**: 基盤層ほど専用テストが薄く、系統缺陥の潜伏性を高める。 +- **修正方針**: async_backend の意味乖離テスト、実 TLS 統合テスト(RELEASE_TODO T6)を追加。 +- **修正コスト**: 中 +- **前提**: なし +- **状態: 対処済み(2026-08-13)**: async_backend 専用テストを約250行に拡充(両バックエンドで + asyncdispatch 24 / chronos 21 テスト成功)。`tests/test_tls_error_paths.nim` 新設で TLS エラーパス + (cert/key/CA 読込失敗 5 種、peer close、ALPN 欠如)をモック+openssl s_server で実証(両バックエンド)。 + 詳細は `.audit/findings/async_backend.md` 追記と `tests/test_ssl_coverage.md`。残は TLS 1.0 ダウングレード + 拒否(TLS1.0 peer が現存しない)等の非現実的経路のみ。 + +### 6. 依存固定・CHANGELOG・semver 運用(S8) +- **重大度**: Medium +- **出現**: chronos/bearssl **nimble 未宣言**(CI 無固定インストール)/依存全て上限なし/CHANGELOG なし/v0.3.0 から **555コミット先行** +- **影響**: 利用側のビルド再現性・互換性リスク。破壊的変更の追跡手段が git log のみ。 +- **修正方針**: chronos/bearssl を nimble に条件付き宣言、CI のバージョン固定、CHANGELOG 導入、0.4.0 リリース。 +- **修正コスト**: 局所(運用整備) +- **前提**: なし + +--- + +## 対処済み(計画的対応 #2 = S1 残存分、2026-08-13) + +- **修正**: `pg_advisory_lock.nim`(withAdvisoryLockCore)/ `pg_largeobject.nim`(withLargeObject)の + `except CatchableError` に `except Defect` を並列追加。body 内 Defect でも unlock / best-effort + loClose を実行し、その後 Defect を re-raise(transaction.nim の慣習パターンと一致)。 +- **回帰テスト**: `withAdvisoryLock releases on Defect` / `withLargeObject closes on Defect` を追加 + (両 async バックエンドで実行確認済み)。 +- **注意**: `except Exception` 一括捕捉は chronos バックエンドの raises 推論で + `raise ref Exception` が unlisted となりコンパイル不可。静的型を明確にするため + CatchableError / Defect の 2 分岐方式が必須。 + +--- + +## 記録のみ + +- **S5 リソースリーク/状態機械の隙間**: pool replenish の maxSize 超過(pg_pool:570-597)、pipeline scsMiss の + prepared statement リーク(pipeline:541,663)、pool 孤立 connect close の asyncSpawn 未追跡(pg_pool:971)は + 3件とも対処済み(2026-08-13〜14、回帰テストあり)。CopyDone race(copy:133,381)は **2026-08-14 対処済み**: + 実 PG(9.1 以降)が COPY 失敗後の stray CopyDone/CopyFail を黙って無視することをワイヤ実験 + PG ソース + (postgres.c "Accept but ignore these messages")で実証し所見の前提(違反応答による desync)を訂正。 + 非標準サーバ対策として CopyDone 前の最終 poll(callbackError パスと対称化)と残留違反応答の + `drainLeftoverToReady` を実装、モック回帰テスト `tests/test_copy_race.nim` を追加(両バックエンド)。 + 詳細は `.audit/findings/pipeline_copy.md`。 +- **S7 エラー契約・文書の不一致**: PgPoolError の判別不能(14箇所・5種以上、文字列のみ)、cleanup doc の + "reporting both" 虚偽(transaction:126,161)。詳細は `.audit/findings/advisory_lo_cluster.md`・`transaction.md`。 +- **32-bit 硬化の不完全性**: pg_protocol decode 側の int64 未拡張 **4箇所**(856,1122,1156,1207)。64-bit では実害なし。 +- **複雑度ホットスポット pg_pool**: 2310行×最高変更頻度×巨大 proc 2件。inline 負債マーカー0(問題がコードに見えない)。 +- **SASLprep テーブル帰属誤り**(pg_saslprep.nim:26,37,115-118): U+200B/U+200C/U+200D の3 code point が PostgreSQL と + 異なる正規化を受け、該当文字を含むパスワードで認証失敗の可能性。単発・低頻度。 +- **サブモジュールハブの型名前空間(2026-08-13、Tier3 完了時)**: `import async_postgres/pg_connection` / + `pg_client` / `pg_types` 単独では公開シグネチャの基盤型(BackendMessage/Row/RowData/bmk*/PgConnection/ + QueryResult 等)が名前解決不能(実コンパイルで確認)。トップハブは完全、値は推論で使用可、`except PgError` + は機能するため Low。詳細は `.audit/findings/tier3_hubs_remaining.md`。 + +--- + +## 構造的所見 +- **依存グラフは清潔**: 循環依存・レイヤ違反ゼロ(DAG)。re-export ハブ構成が整然。 +- **fan-in 集中**: pg_protocol **28**、async_backend **26**(上位2)。基盤変更の波及が大きい。 +- **重複面の契約リスク**: テキスト/バイナリ二重実装(accessors 40分岐・parse/decode 21対・encode 45)は設計特性だが、 + S2 の乖離を生む温床。pumpUntilReady 3種・withTransaction マクロ族14は意図的・文書化済み。 +- **負債管理は外部文書**: inline マーカー0、reviews/(14ファイル)+ 未追跡 RELEASE_0.4.0_TODO.md に集約。規律である一方、 + コードから負債が見えず、本監査で既存レビューの誤りも2件確認(review_decoding.md の fracUs デッドコード誤判定、 + review_encoding.md の一部は修正済み)。 +- **CI/リリース**: 両バックエンド・Nim 3バージョン・実 PostgreSQL で堅牢。一方 OS=ubuntu のみ・PG=18 のみ・ + chronos/bearssl 無固定・dependabot なし。 + +## 良好な点 +- **pg_sql の注入耐性が実測で堅牢**: 全リテラル形式(単一/E/ドル/タグ付きドル/二重引用/行・ネストブロックコメント)で + `{expr}`/`?` を正しく扱い、リテラル内は抽出しない。 +- **MITM 防御が両バックエンドで fail-closed**: CA ピン留め、hostname 検証、pre-TLS インジェクション検知(CVE-2021-23214 系)、 + ALPN 強制、require 以上の平文 fallback 拒否、一時 PEM の 0600+O_EXCL。 +- **SCRAM 実装が防御的**: iteration cap(libpq より厳格)、SASLFinal 必須検証、channel binding downgrade 検出、burnMem 網羅。 +- **int32 長ラップガードが encoder に全適用**(内部プレフィックス11箇所、適用漏れ0)。バイナリデコーダ群も over-read に一貫硬化。 +- 実秘密のコミット無し、examples 14件が現 API と完全整合、quoteIdentifier が libpq 等価。 +- 強いテスト文化(42,157行、両バックエンド、protocol fuzz、network failure)、pumpUntilReady 3種の完全対称、closeTransport 冪等。 + +## 確認したい点 +1. **SASLprep テーブル帰属誤り**: 該当3 code point を含むパスワードの実在頻度が分かれば影響確定。 +2. ~~**raises pragma の実コンパイル不一致**~~: **解決(2026-08-13)**。`raises: []` 実 proc 宣言 10 件を全数 + 精査し Defect 生成操作を網羅列挙。全件遮蔽(except Exception / ガード付き変換)でリーク経路 0 件。 + 詳細は `.audit/findings/raises_contract.md`。strictDefects は利用可能コンパイラに存在せず + (2.0.16〜2.3.1)、Nim 3.x 系での機械再検証のみ留保。 +3. **pg_bearssl の asyncdispatch レグ**: CI で実行されていない(chronos 専用)。意図的か未確認。 + +--- + +## 監査範囲の申告 +- **深掘りしたモジュール(Tier1)**: pg_protocol, pg_types/{decoding,encoding,ranges,core}, pg_connection/buffer_io, + pg_auth(+saslprep), pg_connection/dsn, pg_connection/ssl(+bearssl), pg_pool, pg_client/transaction, async_backend +- **限定調査(Tier2)**: pg_client/{core,query,exec,prepared,cursor,direct,pipeline,copy}, pg_connection/{lifecycle, + simple_query,notify,types}, pg_replication, pg_advisory_lock, pg_largeobject, pg_sql, pg_types/{accessors,array,user_types}, + pg_pool_cluster, pg_errors, pg_bytes +- **機械的スキャン(Tier3)**: ハブ群、cache/type_lookup、transaction_helpers、examples、tests 全体 — **全て完了** + (mechanical.md + tier3_hubs_remaining.md。監査範囲 100%) +- **未調査の領域とその理由**: e2e テスト全文精読(対象モジュール突合は import ベースで実施)、 + CI 実行履歴(リモート未アクセス)、htmdocs/、strictDefects による Defect 効果の機械検証 + (利用可能コンパイラに機能が存在しないため手動網羅で代替、raises_contract.md 参照)。 +- **検証で破棄した所見**: 0件(主要4所見は全て確認。S1 の `-d:release` OOB フレーミングのみ `-d:danger` 限定へ訂正) +- **クラスタ化されず破棄した単発の軽微な指摘**: 約15件(direct マクロの int16 防護迂回、flattenInline の int32 溢出、 + PgParamInline 自己起因過読み、parseTimeTzText オフセット未検証、parseIntervalText 時刻部重複 等、 + 各 findings の「調査したが所見としなかった項目」に記録) +- **推定した暗黙の慣習(AGENTS.md/CLAUDE.md なし)**: 単一例外階層 PgError(pg_errors.nim)、async_backend の + hasChronos/hasAsyncDispatch/hasTls 分岐、`##` RST doc、pg_ snake_case ファイル/PascalCase 型/camelCase proc、 + re-export ハブ構成、PgTracer nil スキップ型フック(観測: async_postgres.nim, pg_errors.nim, async_backend.nim, pg_connection/types.nim)。 + nph フォーマッタを CI で強制(設定ファイルなし・デフォルトスタイル)。 diff --git a/audit/systemic.md b/audit/systemic.md new file mode 100644 index 00000000..e0cea319 --- /dev/null +++ b/audit/systemic.md @@ -0,0 +1,174 @@ +# 横断分析(系統所見) + +個別 findings(`.audit/findings/*.md`)を根本原因でクラスタリングし、リポジトリ全体の実数を付与した。 +単発の Low/Medium は破棄し、系統性で昇格させたものを採用。 + +--- + +## クラスタ S1. 捕捉不能 Defect が PgError エラー契約を抜ける(付録C#5) + +**根本原因**: 境界強制(範囲外・overflow・OOM)を、catchable な `PgError` ではなく uncatchable な `Defect` +(`IndexDefect`/`OverflowDefect`/`OutOfMemDefect`)で実装している箇所が全面に分布する。`raises: []` 契約 +(本番57・テスト64、計121)は CatchableError のみ抑制し Defect は抑制しない。この乖離をコード自身が認識 +(`pg_bearssl.nim:31` "Defect leaks past raises: [] into C (UB)")。にもかかわらず本番の実行時防御 assert は +実質0件(静的2のみ)。利用者は `except PgError`/`except CatchableError` で失敗を捕捉できると期待するが、 +これらの経路ではプロセスが致命的に落ちる(`-d:release` では OOB 読み取り=UB)。 + +**検証済み実数(残存)**: 0 箇所(2026-08-13 対処済み。`except Defect` を並列追加し unlock/loClose +後 re-raise、回帰テスト 2 件追加。詳細は report.md「対処済み」を参照)。 + +**系統性**: 主要な accessor / query 経路に加え advisory_lock / largeobject の残存 2 件も解消。解消済み。 +**raises 契約の実検証(2026-08-13)**: `raises: []` 実 proc 宣言 10 件の Defect 生成操作を網羅列挙。 +全件が `try/except Exception` 遮蔽(Defect <: Exception のため捕捉される)か Defect 生成操作なし。 +pg_bearssl cdecl 2 件のみ `int(len)` 変換が Defect 候補だが、いずれも `high(int)` 事前ガード付き。 +リーク経路 0 件。詳細は `.audit/findings/raises_contract.md`。strictDefects(Nim 3.x)での機械再検証は留保。 + +--- + +## クラスタ S2. テキスト/バイナリ格式の非対称 → 沈黙のデータ損傷 + +**根本原因**: 型システムがテキスト/バイナリの2ワイヤ形式を**並行した別実装**で持つ(accessors 40分岐、 +parse/decode 21対、encode 45)。この重複面自体は設計特性だが、両経路で**検証の厳密性と正規化が乖離**し、 +同一値が形式によって異なる結果になる。バイナリ経路は厳密(raise)なのにテキスト経路は黙受、またはその逆。 + +**残存**: 0件(全 10 箇所が対処済み、High → 解消)。 + +--- + +## クラスタ S3. asyncdispatch/chronos キャンセル非対称(asyncdispatch が既定) + +**根本原因**: `async_backend.nim` が2バックエンドを抽象するが、asyncdispatch(**既定**、async_backend.nim:9)には +真正キャンセルが無く `cancelAndWait` は no-op(193-204)。chronos はキャンセル可能。この意味差の吸収は +全経路で規律化済み。 + +**残存**: +- **抽象の漏れ出し**(async_backend 所見2,3、Low): chronos `export` による wait オーバーロード解決の非対称(未文書)、 + toMilliseconds sub-ms 切り捨て(asyncdispatch のみ即タイムアウト、2箇所)。 +- **3引数 wait ラッパの chronos void コンパイル不能**(2026-08-13 観測、Low): `return await chronos.wait` + の void 特化が chronos asyncmacro の result 曖昧化でコンパイル不能。本番は2引数形式のみで潜伏。 +- **Moment 減算の負値クランプ**(2026-08-13 観測、Low): chronos は負を 0 にクランプ、asyncdispatch は + 符号付き。本番使用は正値側のみで実害なし。 +- **クライアント cert/key 不一致検出の非対称**(2026-08-13 観測、Low): asyncdispatch は + check_private_key で fail-closed、chronos はサーバがクライアント認証を要求しない限り黙って成功。 + +**系統性**: 主要な systemic 経路(transaction / fillRecvBuf / TLS / 証明書診断 / finally:await)は全対処済み。High → 解消。 + +--- + +## クラスタ S4. 確保増幅 / 無制限蓄積 DoS + +**根本原因**: サーバ制御の長さフィールドが駆動する確保に**集約上限**が無い。`DefaultMaxBackendMessageLen`(1 GiB) +は recvBuf 成長のみを上限化し、parse 時のヒープ確保を上限化しない(doc は OOM 防止を明言するが recvBuf についてのみ成立)。 + +**残存**: +- `copyOutImpl`(copy.nim:476)の無制限蓄積: per-message cap(1 GiB)あり、aggregate cap なし。バッファ型は緩和なし。 +- encode 側のガード前確保: **2系統**(toBytes core.nim:1083=テキスト encoder 約15共用、encodeJsonbBinary encoding.nim:1011)。 + 下流ガードが捕捉するが一時约2倍メモリ。 + +**系統性**: 2箇所(copy 蓄積 + encode 2系統)。Medium。 + +--- + +## クラスタ S5. リソースリーク / 状態機械の隙間 + +**根本原因**: 並行タスクの追跡・状態遷移の網羅に局所的な隙間。 + +**実数**: +- ~~pipeline の scsMiss 失敗 op の prepared statement リーク(pipeline.nim:541,663)~~: **対処済み(2026-08-13)**。 + 失敗した statement の Close をキューに載せ回収(#589/#590)。 +- ~~pool maintenanceLoop replenish の maxSize 超過(pg_pool.nim:570-597)~~: **対処済み(2026-08-14)**。 + in-flight replenish connect を `pool.active` で予約計上(予約規律の唯一の違反を解消)。 + 競合テスト 3 件追加。詳細は `.audit/findings/pool.md` 所見1。 +- ~~pool 孤立 connect close の asyncSpawn 未追跡(pg_pool.nim:971)~~: **対処済み(2026-08-13)**。 + `pendingBackgroundTasks` に登録して close() drain で待機(#590)。 +- CopyDone race(copy.nim:133,381): **対処済み(2026-08-14)**。前提を実証検証したところ、実 PG(9.1 以降)は + COPY 失敗後の stray CopyData/CopyDone/CopyFail をプロトコル仕様に従い黙って無視する + (postgres.c "Accept but ignore these messages")ため desync は発生しないことをワイヤ実験で確認(所見の前提崩壊)。 + 非標準サーバ対策として防御的硬化を実装: CopyDone 前の最終 poll(callbackError パスと対称化)+ エラー時の + `drainLeftoverToReady`(残留違反応答の消費)+ モック回帰テスト 2 件(`tests/test_copy_race.nim`)。 + 詳細は `.audit/findings/pipeline_copy.md`。 + +**系統性**: 4件全て対処済み(2026-08-13〜14)。CopyDone race は実サーバでは発生しないことを実証(前提訂正)しつつ、非標準サーバ対策の硬化を追加。 + +--- + +## クラスタ S6. テストの構造的空白 + +**根本原因**: 基盤層・キャンセル経路・malformed 拒否・実TLS にテストが構造的に存在しない。 + +**残存**: +- ~~`async_backend.nim`: fan-in **26**(全模块2位)に対し専用テスト **36行**~~: **対処済み(2026-08-13)**。 + wait/asyncSpawn/allFutures/cancelAndWait/sleepAsync/cancelTimer/scheduleSoon/registerFdReader/ + completed/Duration/Moment/remainingDeadlineDuration/makeAsyncSinkByteCallback に専用テストを追加 + (asyncdispatch 24件・chronos 21件、両バックエンドで成功)。 +- 実 TLS 統合テスト: driveTlsHandshake エラー分岐/assertAlpnPostgres エラーパス/暗号化鍵拒否/成功パスがモック到達不能 + (tests/test_ssl_coverage.md が列挙、RELEASE_TODO T6)。asyncdispatch TLS 下限 (TLS 1.2+ 強制) の + downgrade 拒否、chronos verify-full + IP-literal host 事前診断、chronos require での期限切れ証明書拒否も + 同 doc に追記済み。 + - **対処済み(2026-08-13、T6 の大半)**: `tests/test_tls_error_paths.nim` を新設。 + - クライアント cert/key/CA 読込失敗分岐(CA ゴミ / cert ゴミ / key ゴミ / 暗号化鍵拒否 / cert-key + 不一致)— モック 'S' 応答のみで establishTls のロード段階まで到達、両バックエンド 5 テスト。 + - driveTlsHandshake の peer close 分岐(asyncdispatch)+ chronos の handshake EOF。 + - assertAlpnPostgres エラーパス(「without ALPN」)— openssl s_server(ALPN 非広告、PG<17 相当)で + 両バックエンド実証。chronos は BearSSL クライアント × OpenSSL サーバの実本番構成。 + - **残(到達不能/非現実的)**: TLS 1.0 ダウングレード拒否(modern OpenSSL に TLS1.0 peer が存在しない)、 + BIO_read/write 内部失敗、SSL_CTX_set_alpn_protos の dynlib nil 分岐。 +- pg_bearssl の asyncdispatch レグ未実行(chronos 専用)。 + +**系統性**: 基盤層ほど専用テストが薄い傾向。Medium(構造)。 + +--- + +## クラスタ S7. エラー契約・文書の不一致(付録C#5) + +**根本原因**: 利用者が見る doc/型と実装の乖離。 + +**実数**: +- `PgPoolError` の判別不能(pg_errors.nim:100): **14箇所・5種以上**の意味的に異なる失敗が同一型・文字列のみ。 + リトライ可否のプログラム的判別不可能。 +- cleanup doc の "reporting both" 虚偽(transaction.nim:126,161): コードは csrConnInvalidated のみ報告、 + tsIdle スキップは無音。`CleanupSkipReason` に該当コード無く実装不可能。**2箇所**。 +- doc 矛盾: encoding「Mirror decodeNumericBinary」(虚偽、decoder に検査無し)。 + +**系統性**: 3系統。Medium/Low。 + +--- + +## クラスタ S8. 依存/ビルド/リリースの再現性(付録C#2,3,4) + +**根本原因**: 依存の固定と変更履歴の管理が弱い。 + +**実数**: +- `chronos >= 4.4.0` と `nim-bearssl >= 0.2.11` は README 言及・コードで import されるが **nimble 未宣言**。 + CI は `nimble install chronos -y` で**無固定**インストール。 +- nimble 依存は全て `>=` の下限のみ・**上限なし**(nim/nimcrypto/checksums/unicodedb/normalize)。 +- **CHANGELOG ファイル無し**。HEAD は v0.3.0 から **555コミット先行**(約2ヶ月未リリース)。 + 破壊的変更の追跡は git log/PR のみ。0.4.0 計画は未追跡の reviews/RELEASE_0.4.0_TODO.md。 + +**系統性**: 再現性・互換性リスク。Medium。 + +--- + +## 地図由来の構造的所見 + +- **循環依存・レイヤ違反: なし**(依存グラフは DAG、良好)。 +- **fan-in 集中**: pg_protocol **28**, async_backend **26**, pg_typesハブ 21, pg_connectionハブ 17, pg_errors 13。 + 基盤2模块の変更が全体に波及。 +- **複雑度ホットスポット**: pg_pool.nim **2310行**(最大)× 最高変更頻度(103)× 巨大 proc 2件(notify 437, acquireImpl 240) + × inline 負債マーカー0(問題がコードに見えない)。S5 の所見がここに集中。 +- **負債管理は外部文書**: inline マーカー0、既知問題は reviews/(14ファイル)+ 未追跡 RELEASE_0.4.0_TODO.md に集約。 + 規律である一方、コードから負債が見えない。 + +## 良好な点(壊してはいけない設計判断) + +- 依存グラフが清潔(循環・レイヤ違反なし)、re-export ハブ構成が整然。 +- **pg_sql の注入耐性が実測で堅牢**(全リテラル形式: 単一/E/ドル/タグ付きドル/二重引用/行・ネストブロックコメントで + `{expr}`/`?` を正しく扱い、リテラル内は抽出しない)。 +- **MITM 防御が両バックエンドで fail-closed**: CA ピン留め、hostname 検証、pre-TLS インジェクション検知(CVE-2021-23214 系)、 + ALPN 強制、require 以上の平文 fallback 拒否、一時 PEM の 0600+O_EXCL。 +- **SCRAM 実装が防御的**: iteration cap(<4096 / >10M 拒否、libpq より厳格)、SASLFinal 必須検証、channel binding downgrade 検出。 +- **int32 長ラップガードが encoder に全適用**(内部プレフィックス11箇所全て、適用漏れ0)。 +- **バイナリデコーダ群が over-read/巨大確保に一貫硬化**(count 事前抑止 + 要素毎 pos 検査)。 +- 実秘密のコミット無し、examples 14件が現 API と完全整合、quoteIdentifier が libpq 等価。 +- 強いテスト文化(42,157行、両バックエンド実行、protocol fuzz、network failure)。 +- pumpUntilReady 3種のエラー/キャンセル処理が完全対称、closeTransport 冪等。 diff --git a/reviews/RELEASE_0.4.0_TODO.md b/reviews/RELEASE_0.4.0_TODO.md new file mode 100644 index 00000000..12fe3161 --- /dev/null +++ b/reviews/RELEASE_0.4.0_TODO.md @@ -0,0 +1,98 @@ +# 0.4.0 リリース前 要修正項目 (次候補) + +前バージョンの blocker 6件と補足 (`decodeCreateSlotRow` private 化) はマージ済み +(2d3ed11, 365edff, 3c6f486, 44fa0cf, 88c680a)。本ファイルは reviews/ 配下 14 ファイル ++ tests/review_rowdata.md / tests/test_ssl_coverage.md を再走査し、 +main (04911c8) 時点で未対応の項目を再抽出したもの。 + +## コード修正 (優先度順) + +| # | 箇所 | 概要 | 深刻度 | 根拠 | +|---|------|------|--------|------| +| 5 | `async_postgres/pg_connection/buffer_io.nim:169` (`compactRecvBuf`) | `csClosed` 以降呼ばれない不変条件が防御コードで担保されていない。`doAssert conn.state != csClosed` 追加で将来の破壊を防ぐ | Low (precautionary) | buffer_io_review.md #2 | + +## 既に対応済み + +- **#1 `async_postgres/pg_types/ranges.nim` `parseMultirangeText`** — c8fa5eb + (PR #569) で main へマージ済。カンマ後の空白スキップを実装し、空 range 分岐にも + 同スキップを追加、line 727-728 の dead `discard` 分岐を削除。`tests/test_types.nim` + に "parse multirange with spaces after comma" / "parse multirange with empty + and spaces" を追加。REVIEW_ranges.md #1, #2 は close 済。 +- **#2 `async_postgres/pg_protocol.nim:1022-1024, 1035-1039`** `reuseRowData` + 両オーバーロードの doc コメント修正は 04911c8 (PR #570) で main へマージ済。 + "left intact" 記述と存在しない `QueryResult` 参照を削除し、`rd` は valid ref + のままだが `buf`/`cellIndex` (第2オーバーロードでは加えて `colFormats`/ + `colTypeOids`) が空になる旨を明記。pg_protocol_review.md #1 は close 済。 +- **`async_postgres/pg_pool.nim:354-358`** `resetSession` の `CatchableError` ハンドラは + 現在 `conn.state = csClosed` のみ設定する対称パターンで実装済。TODO 初期リストの + 想定 (`tracedClose` 明示呼び出し) は解消されている。pg_pool_review.md #1 は close 済。 +- **#3 `async_postgres/pg_types/encoding.nim:1083-1098`** `toPgBinaryParam(PgPath/PgPolygon)` + に payload サイズガードを追加 (branch `fix/pg_types-path-polygon-binary-payload-guard`、 + 27c7cd4、未 PR)。当初の TODO では「他エンコーダは `checkPgBinLen` 適用済で不整合」と + 記載していたが、実調査では `checkPgBinLen` を使うのは `encodeBinaryArray` / + `encodeHstoreBinary` のみで、fixed-width element を扱う `buildFixedArray` は + payload-level ガード (`if payload > int32.high.int64: raise`) を採用していた。 + PgPath/PgPolygon も element size 固定 (16 bytes) のため後者と同じスタイルが + 適切。`checkPgBinPayload(size, "path"/"polygon")` を int64 演算で挿入し、 + `newSeq[byte](size.int)` で 32-bit プラットフォームでの `points.len * 16` + int wrap も同時にブロック。point-count の int32 キャストは payload 上限 + (16*n ≤ int32.high) で自動的に包含。既存の PgPath/PgPolygon happy-path テスト + および roundtrip テストは全 pass、リグレッションなし。review_encoding.md #2 は close 済。 +- **#4 `async_postgres/pg_client/transaction.nim` (非リトライ版 `withTransaction`)** — 現ブランチ + `fix/withtransaction-cancel-cleanup` の f2867ac で実装済 (未 PR)。conn 版 `withTransaction` / + `withSavepoint`、pool 版 `withTransaction` / `withTransactionDeadline` / + `withTransactionRetryDeadline` の各 body try に `except CancelledError as e: raise e` を追加し、 + `buildRollbackCleanup` / `buildSavepointRollbackCleanup` / `buildDeadlineAwaitAndTimeout` の + cleanup-cancel は swallow して原因例外を再送出する対称形に統一。回帰テスト + `tests/test_transaction_cancel.nim` (chronos-only、3 テスト) を追加。review-transaction.md + Issue 2 は close 済。 + +## ドキュメント修正 (同梱推奨) + +| # | 箇所 | 概要 | 根拠 | +|---|------|------|------| +| 6 | `async_postgres/pg_advisory_lock.nim` `withAdvisoryLockCore` doc | body 内 `return`/`break`/`continue` で unlock がスキップされる制約と、実効最大待ち時間が `2 * timeout` になり得る旨を追記 | pg_advisory_lock_review.md #1, #2 | +| 7 | `async_postgres/pg_replication.nim` `readReplicationSlot` doc | `outputPlugin` は `READ_REPLICATION_SLOT` 返却列に含まれず常に空である旨を追記 | pg_replication_review.md #2 | +| 8 | `async_postgres/pg_pool.nim` `close()` doc | asyncdispatch の `cancelAndWait` no-op により、`close()` 返回後にメンテナンスループ由来の close タスクが完走する可能性を追記 | pg_pool_review.md #2 | +| 9 | `async_postgres/pg_client/copy.nim` `copyInStream` doc | 送信サイズ上界がコールバックのチャンクサイズに依存する旨を追記 (`copyInRaw` は 256KB 固定) | pg_copy_review.md #1 | + +## テスト整備 (0.4.0 スコープ外でも順次) + +| # | 対象 | 概要 | 根拠 | +|---|------|------|------| +| T1 | `tests/test_rowdata.nim` | `parseDataRowInto` の "message too short" / 負の col count / "unexpected end" / "invalid column length" / "truncated" 各エラー分岐 + ロールバック検証。`buildResultFormats` 直接テスト。0列 DataRow (`body = [0x00, 0x00]`) の境界テスト | tests/review_rowdata.md | +| T2 | `tests/test_rowdata.nim` line 180, 199 | テスト名リネーム (`"remains intact after reuse"` → `"reuse moves buffers out"` 等)。line 291-312 の ~2 GiB 確保テストは CI メモリ制約次第で調整 | tests/review_rowdata.md | +| T3 | `tests/` 新規 | 空 PEM ファイル (`dsn.nim:242-243` の `result.len == 0` ガード) の拒否テスト。`initConnConfig` の SSL cert 検証は test_ssl.nim/test_pool.nim で部分的にカバー済のため未カバー中核はこの1点 | review_dsn.md 未カバー欄 | +| T4 | `tests/` 新規 | encoding / decoding / ranges / DSN / SQL 向け fuzz テスト (現状 `test_protocol_fuzz.nim` はプロトコルのみ) | rest_review.md | +| T5 | `tests/test_types.nim` (9062 行) | encoding / decoding / ranges への分割リファクタ。境界の曖昧さでギャップが埋もれる | rest_review.md 構造的問題 | +| T6 | ~~`tests/test_ssl.nim`~~ | ~~実 TLS 統合テスト~~ → **対処済み (2026-08-13)**: `tests/test_tls_error_paths.nim` 新設。cert/key/CA 読込失敗・暗号化鍵拒否・cert-key 不一致 (asyncdispatch)・peer close・ALPN 欠如を実証 (両バックエンド)。残は TLS 1.0 ダウングレード拒否 (peer 構築不能) 等の非現実的経路のみ | tests/test_ssl_coverage.md | + +## 対応不要 (再確認) + +- **review_decoding.md #1** `parseIntervalText` years 境界 — 実測で off-by-one ではないことを確認済。Nim の `div` は truncated (toward zero) のため `int64(int32.low) div 12 = -178956970` / `int64(int32.high) div 12 = 178956970` となり、`val = ±178956970` は `val * 12 = ±2147483640` で int32 に収まり、`val = ±178956971` は `val * 12 = ±2147483652` で int32 を超え reject。境界の check_passes と fitsI32 は完全一致。加えて 12 は int32.high/int32.low の約数ではないため `val * 12` が `[2147483641, 2147483647]` や `[-2147483648, -2147483641]` の値を取ることは原理的にない +- **pg_pool_review.md #3** `dispatchHomogeneous` の `CancelledError` 捕捉 — 現状 `asyncSpawn` fire-and-forget で発火経路なし。防御コメントで十分 +- **pg_pool_review.md #4** `splitBatchBudget` cap 超過 — 意図的 (Info) +- **pg_advisory_lock_review.md #3** `ensureXactScope` メッセージ — Very Low (末尾に txStatus 表示あり) +- **pg_advisory_lock_review.md #4** raw/typed mixed unlock counter — 文書化・テスト済 +- **pg_copy_review.md #2** `copyIn(seq[seq[byte]])` ピークメモリ — 設計上のトレードオフ、doc で `copyInStream` に誘導済 +- **pg_protocol_review.md #2** `addBindRaw` int32 演算 — サーバ側 1 GiB 制限で実質発生不可 +- **buffer_io_review.md #1, #3, #4, #5** — Low / doc 改善余地のみ +- **REVIEW_largeobject.md** — 全 Low、`loImport`/`loExport` はサーバ FS アクセスで意図的除外 +- **REVIEW_ranges.md #3** — 機能的同等 (リファクタ余地のみ) +- **REVIEW_ranges.md #4, #5** — 放置可 +- **review_dsn.md** バグなし +- **review_pg_sql.md** 全項目実害なし +- **rest_review.md #1 (test_ssl)** — 1dfc8ea で split-write MITM 追加済 (T6 は残る) + +## リリース判断 + +- **#1 は c8fa5eb (PR #569) で main へマージ済。** +- **#2 は 04911c8 (PR #570) で main へマージ済。** +- **#3 は branch `fix/pg_types-path-polygon-binary-payload-guard` (27c7cd4) でパッチ準備済み。PR 化待ち。** +- **#4 は現ブランチ `fix/withtransaction-cancel-cleanup` (f2867ac) で対応済。PR 化待ち。** +- **#5 は低リスクだが防御性の向上。同梱推奨。** +- **#6〜#9 は doc のみ、変更差分小。同梱容易。** +- **テスト整備 T1〜T6 は 0.4.0 後の継続タスクでも可。ただし T1 (parseDataRowInto 決定的エラーテスト) は decoding 変更を安全にする観点で早期対応が望ましい。** + +残るコード修正は #5 の 1 件のみで、いずれも局所的 (数行〜十数行)。パッチとテスト +追加は 1 リリースサイクル内に十分収まる規模。 diff --git a/reviews/REVIEW_largeobject.md b/reviews/REVIEW_largeobject.md new file mode 100644 index 00000000..54e08d64 --- /dev/null +++ b/reviews/REVIEW_largeobject.md @@ -0,0 +1,34 @@ +# tests/test_largeobject.nim レビュー結論 + +## 指摘1: `loWriteStreamDeadline` に `chunkSize = -1` のテストがない + +**判定: Low / 実質リスクなし** + +`loWriteStream` (`pg_largeobject.nim:356`) と `loWriteStreamDeadline` (`pg_largeobject.nim:474`) のガードは同一 (`chunkSize <= 0 or chunkSize > high(int32)`)。 +`chunkSize = 0` のテストが既に `<= 0` 分岐を行使しており、`-1` を追加しても新しいコードパスはカバーされない。 +対称性・可読性の観点で追加してもよいが、リグレッション検出の実益は薄い。 + +## 指摘2: `discard await lo.loWrite(bigData)` (test:534) + +**判定: Low / 現実的に発生しない** + +`loWrite` (`pg_largeobject.nim:165`) は PostgreSQL の `lowrite()` を1回呼び出す。 +`lowrite` は POSIX `write()` と異なり部分書き込みをせず、内部の `inv_write` がバッファ全体を書く。失敗時は例外が伝播する。 +部分書き込みが起きて `discard` が問題を隠すシナリオは現実的に存在しない。 +仮に書き込みが失敗しても、後続の `loReadAllDeadline` が期待する 1MB を読み取れずテストが失敗するため、間接的には検出される。 + +## カバレッジ + +公開proc 21個中、テストでカバーされているのは 19個。未カバーは以下の2つ: + +| Proc | 行 | 状態 | +|------------|-------------------------|----------| +| `loImport` | `pg_largeobject.nim:209` | テストなし | +| `loExport` | `pg_largeobject.nim:218` | テストなし | + +これらはサーバ側のファイルシステムにアクセスするため、テスト環境の制約で意図的に除外されている可能性がある。 +それ以外のprocは全て、正常系・異常系(chunkSize検証、64ビットオフセット、タイムアウト発火含む)のテストが存在する。 + +## 総合判断 + +バグ・セキュリティ問題なし。指摘2件はともに Low / Nice-to-have。 diff --git a/reviews/REVIEW_ranges.md b/reviews/REVIEW_ranges.md new file mode 100644 index 00000000..2a0f34e0 --- /dev/null +++ b/reviews/REVIEW_ranges.md @@ -0,0 +1,59 @@ +# Code Review: async_postgres/pg_types/ranges.nim + +## #1: parseMultirangeText が空白を処理しない (Medium) — 対応済み (c8fa5eb / PR #569) + +**lines 724-746** + +`{[1,2), [3,4)}` のようにカンマ後に空白を含む入力でパース失敗する。 +最初の range パース後 `start` が空白位置を指し、次の range 抽出時に +`rangeStr = " [3,4)"` となり `parseRangeText` で `s[0]=' '` → `lowerInc=false`(誤り)、 +`lowerStr="[3"` → `parseElem("[3")` が例外。 + +PostgreSQL のサーバ出力は空白を含まないため、public API にユーザーが +手書きリテラルを渡した場合のみ発生。 + +**対応**: c8fa5eb (PR #569) で main へマージ済。カンマ直後の空白を +`while ... == ' '` でスキップ。通常 range と "empty" 分岐の両方に適用。 +`tests/test_types.nim` に `"parse multirange with spaces after comma"` と +`"parse multirange with empty and spaces"` を追加。 + +## #2: dead code (line 727-728) (Low) — 対応済み (c8fa5eb / PR #569) + +```nim +if depth == 0 and i > start: + discard +``` + +`discard` は no-op。#1 の空白スキップを実装する意図だったと推測されるが未実装。 + +**対応**: c8fa5eb (PR #569) で #1 修正と同時に削除。 + +## #3: toPgDateMultirangeArrayParam のロジック重複 (Low) + +**lines 995-1024** + +range フォーマット処理をインラインで手動展開しているが、 +`encodeDateTimeMultirangeArrayText(v, pgDateRangeFmt)` と機能的に同等。 +`quoteRangeElem` の有無の違いがあるが `yyyy-MM-dd` 出力にはクォート対象文字が +含まれないため結果は同一。 + +**対応**: `encodeDateTimeMultirangeArrayText` 利用に統一する。 + +## #4: "empty" 検出後にループ変数 i を進めない (Low) + +**lines 741-746** + +`start` だけ進み `i` は 'e' の位置のまま。残り4文字(m,p,t,y)は +`i != start` 条件で何もしない。機能的に正しく4イテレーションの無駄のみ。 + +**対応**: 放置可。 + +## #5: decodeNumRangeBinary の zero-length bound (Low) + +**lines 122-137** + +`lowerLen == 0` 時 `toOpenArray(off, off-1)` は空 openArray となり +`decodeNumericBinary` が例外を投げる。PostgreSQL の numeric バイナリは +最低8バイト(ndigits, weight, sign, dscale)なので正常なサーバ応答では発生しない。 + +**対応**: 放置可。 diff --git a/reviews/buffer_io_review.md b/reviews/buffer_io_review.md new file mode 100644 index 00000000..9180e29a --- /dev/null +++ b/reviews/buffer_io_review.md @@ -0,0 +1,59 @@ +# buffer_io.nim レビュー結論 + +## 指摘1: `configureTcpNoDelay` のエラー握りつぶし — 妥当 (低) + +`buffer_io.nim:746-748` で `discard setsockopt(...)` により失敗を無視。 +`configureKeepalive` (line 754) は `setSockOptInt` 経由で失敗時に `PgConnectionError` を送出するため一貫性がない。 + +TCP_NODELAY は性能ヒントであり失敗しても接続は成立するため、意図的な設計と判断できる。 +ただし意図を示すコメントがなく誤解を招く。 + +## 指摘2: asyncdispatch `fillRecvBuf` 孤立書き込み — 妥当 (中・現在は安全) + +`buffer_io.nim:220-235` でタイムアウト時に `recvBuf.setLen(oldLen)` で切り詰めるが、 +孤立した `recvInto` が `recvBuf[oldLen..]` への生ポインタを保持し続ける。 + +安全根拠: +- `invalidateOnTimeout` (`simple_query.nim:211-226`) が `csClosed` に設定し `PgTimeoutError` を送出 +- `csClosed` 後は `fillRecvBuf`/`compactRecvBuf` が再呼び出しされない設計 +- seq の `setLen` による縮小はキャパシティを維持するため、孤立書き込みは確保済みメモリ内に留まる + +現在は安全だが、不変条件はコメントのみで維持されており、`compactRecvBuf` (line 169) に +`doAssert conn.state != csClosed` のような防御的アサーションはない。 +将来のコード変更で破壊されるリスクあり。 + +## 指摘3: line 357 のフルパース — 妥当だが現在は到達不能 + +`buffer_io.nim:357-359` のパスは `rowData == nil` かつ `skipDataRow == false` かつ +`onRow == nil` のときのみ到達可能。 + +実際の呼び出し確認: +- `cursor.nim:62,148`: `nextMessage(rowData, addr count)` — rowData あり +- `pipeline.nim:459,626`: `nextMessage(rowData, rowCount)` — rowData あり +- `recvMessage` の唯一の呼び出し (`notify.nim:167`): 引数なし + +現在このパスに到達する呼び出しは存在しない。 +将来 `recvMessage(rowData = nil, rowCount = addr n)` のような使い方がされた場合に +アロケーションの無駄が発生する潜在的な非効率。 + +## 指摘4: `readyBody` 例外による `queryError` 隠蔽 — 妥当 (低・設計) + +`buffer_io.nim:409-411` (3つのオーバーロード全て) で `readyBody` が例外を送出すると +`queryError` は失われる。 + +`readyBody` は状態管理(トランザクション状態の記録など)であり例外を送出しない前提。 +実害はほぼないが、サーバーエラーを確実に伝播させたい場合は +`readyBody` を try/except で囲む検討の余地あり。 + +## 指摘5: `onRow` パスの nil ガードなし — 妥当 (情報) + +`buffer_io.nim:327,334` で `onRowError[]` と `rowData.buf` を nil チェックなしで参照。 +doc comment (line 293-296) で契約を明記。 + +契約ベースの設計として妥当。`doAssert` を追加すれば開発時のみ検出可能 +(リリースではコンパイル除外)で、実行時コストなし。 + +## 総合評価 + +重大なバグはなし。指摘2の孤立書き込みが最も注意を要するが、現在の設計では安全。 +防御的改善として `compactRecvBuf` へのアサーション追加が最も費用対効果が高い。 diff --git a/reviews/listen_close_reconnect_race_deep_review_fc25c0f.md b/reviews/listen_close_reconnect_race_deep_review_fc25c0f.md new file mode 100644 index 00000000..ebf4a43d --- /dev/null +++ b/reviews/listen_close_reconnect_race_deep_review_fc25c0f.md @@ -0,0 +1,131 @@ +# 深層コードレビュー — fc25c0f + +**対象**: コミット `fc25c0f3a19036331718111e028069408f4bcdb7`(`git show`) +**ブランチ**: fix/listen-close-reconnect-race +**規模**: 4 ファイル / +491 −33 行 +- async_postgres/pg_connection/lifecycle.nim | 22 +- +- async_postgres/pg_connection/notify.nim | 84 ++++-- +- async_postgres/pg_connection/types.nim | 17 +- +- tests/test_listen_reconnect.nim | 401 ++++++++++++++++++++++++++++- + +**変更の要約**: asyncdispatch ではブロッキング `connect()` 内の listen ポンプをキャンセルできないため、`close()`/`stopListening()` がハングまたは接続を復活させるレースを修正。有制限待ち(`listenReconnectStopWaitMs`)+ orphan 化+ `listenStopRequested` セット保持を導入し、`reconnectInPlace` が `connect()` 返回後に flag をチェックして新 transport を廃棄するようにした。`stopListening` はタイムアウトで `PgTimeoutError` を投げる。 + +**手法**: 文脈構築サブエージェント5並列 → 9観点レビューエージェント並列 → 指摘ごとに独立検証エージェント(確認/反証/判断不能)。検証で反証された指摘は破棄済み。 + +--- + +## Critical + +なし。 + +## High + +なし。 + +## Medium + +### 1. `close()` の orphan 化が完全黙殺で、確立された tracer 慣習と不整合(運用時に orphan リークが無痕跡になる) +- **場所**: `async_postgres/pg_connection/lifecycle.nim:468-469` +- **確信度**: 確定(検証エージェントが 9-b の3条件充足を確認) +- **何が起きるか**: asyncdispatch で listen ポンプがブロッキング `connect()` 内にあり有制限待ちがタイムアウトすると、`close()` は orphan ポンプと新ソケットを残して戻るが、例外も tracer イベントも一切出さない。同じ orphan 条件でも `stopListening` は `PgTimeoutError` を投げて可視化する(`notify.nim:359-363`)。このコミットが標的とする「connect() が巻き戻らない」ケースでは orphan が恒久的に漏れ得るが、tracer を設定した運用者にはログ・メトリクスに痕跡ゼロ。 +- **再現条件**: asyncdispatch。listen ポンプが `reconnectInPlace` のブロッキング `connect()` 中に `close()` を呼び、`listenReconnectStopWaitMs` 内に connect が復帰しない。tracer 有無を問わず無痕跡。 +- **根拠**: + ```nim + # lifecycle.nim:468-469 + except AsyncTimeoutError: + discard + ``` + teardown で飲み込んだエラー/不可視状態を tracer へ流す慣習は比較可能な多数箇所で一貫: + ```nim + # buffer_io.nim:589-613(closeTransport)— 5箇所の closeWait 失敗を全て流す + try: + await conn.tlsStream.reader.closeWait() + except CatchableError as e: + conn.fireTransportCloseError(tcsTlsReader, e) + ``` + ``` + # types.nim:919-921(フック doc) + ## the error cannot be propagated to a caller — tracing is the only signal operators have + ``` + 同系統: `fireCleanupSkipped`(types.nim:900-913)、`fireAdvisoryUnlockFailed`(pg_advisory_lock.nim:412,414)、`reportCloseError`/`onPoolCloseError`(pg_pool.nim:254-260)。pool の `tracedClose`(pg_pool.nim:264-267)は `close()` が **raise** したエラーしか拾わないため、本パスの `discard` ではその共通経路すら素通りする。既存の `except AsyncTimeoutError` 箇所(pg_pool.nim:1055, 2304-2308 等)は raise または直後回収を伴い、tracer も回収もない純黙殺は本箇所が唯一。 +- **検証**: 検証エージェントが 9-b の3条件(3箇所以上の一貫性・具体的な代償・パターンが割れていない)をすべて充足と確認。`AsyncTimeoutError` の orphan 黙殺は本コミットが新規導入(旧コードは無制限 `await pump` で timeout-orphan 自体が存在しない)。 +- **提案**: 既存 `fireTransportCloseError` と同じ形(`conn.config.tracer` 参照、nil フックは no-op)で orphan 発生を tracer へ流す。該当フックが無ければ専用フック追加。`stopListening`(raise)との可観測性非対称も解消される。 + +## Low + +### 2. 新規追加の invariant doc がコードの実際の保証範囲を超えている(通常パスのエラー分岐が doc と矛盾) +- **場所**: `async_postgres/pg_connection/types.nim:263-264`(doc)/矛盾箇所 `async_postgres/pg_connection/notify.nim:377-387` +- **確信度**: 高(レース成立は検証確認。ただし復活レース本体は既存、doc 矛盾がインスコープ) +- **何が起きるか**: 本コミットが追加した `listenStopRequested` の doc は「live orphan を持つ csClosed 接続で flag をクリアするな」と定めるが、`stopListening` の通常パス(非 reconnecting)のエラー分岐はまさにそれを行う。`sendMsg` が失敗すると `abortListenTask`(asyncdispatch で no-op)→ `listenTask=nil` → `finally` で flag クリア。しかしポンプはまだ recv 失敗を処理しておらず、クリア後に再接続ループに入り、flag が false のため `reconnectInPlace` が新 transport を graft して接続を復活させ得る(`listenTask=nil` 済みで以降 `close()` はポンプに触れない)。 +- **再現条件**: asyncdispatch。`listen()` 後ポンプが recv ループ(csListening・非 reconnecting)にある状態で、`stopListening` の `sendMsg` 失敗とポンプの recv 失敗継続が競合。 +- **根拠**: + ```nim + # types.nim:263-264(本コミットが追加した invariant) + ## post-connect graft is disarmed — do not clear it while a csClosed + ## connection may still hold a live orphan. + ``` + ```nim + # notify.nim:377-387(通常パスエラー → finally で無条件クリア) + except CatchableError: + # Send or pump failed: connection is dead + await conn.abortListenTask() + conn.listenTask = nil + ... + finally: + if not preserveStopFlag: # この分岐では false のまま + conn.listenStopRequested = false + ``` + `preserveStopFlag` は `AsyncTimeoutError` 分岐(:356)でしか true にならないため、通常パスエラーでは flag がクリアされる。`abortListenTask` の `cancelAndWait` は asyncdispatch で no-op(async_backend.nim:193-204)なのでポンプは生存し、`notify.nim:230` の `not conn.listenStopRequested` が true で graft へ進む。 +- **検証**: 検証エージェントがレースの成立を**確認**。ただし `git show fc25c0f^` で、通常パスのエラー分岐と finally での flag クリアはコミット前後で**完全に同一**(diff はコメント行削除のみ)であり、復活レース自体は**変更以前から存在**すると判定。したがって復活バグ本体は付録A「既存の問題」に該当し、ここでは**本コミットが追加した doc invariant がコードの実際の保証と矛盾する点**をインスコープの指摘とする。 +- **提案**: 二択。(a) 通常パスエラー分岐でも、ポンプの停止を確認できていないなら `preserveStopFlag = true` 相当で flag を保持する(本コミットの修正を同族パスへ拡張)、または (b) doc invariant を「reconnecting パスのタイムアウト時に限る」と正確化する。いずれにせよ doc とコードの保証範囲を一致させること。 + +### 3. `stopListening` の `PgTimeoutError` 経路が `listenTask` を nil 化せず、doc 指示通りの `close()` が再度フルタイムアウト待つ(累計最大約20秒) +- **場所**: `async_postgres/pg_connection/notify.nim:351-363`(raise)→ `async_postgres/pg_connection/lifecycle.nim:453-466`(再待機) +- **確信度**: 確定(検証エージェントが全5点をコードで確認) +- **何が起きるか**: `stopListening` が `AsyncTimeoutError` で orphan 化して `PgTimeoutError` を raise するとき、`:366` の `conn.listenTask = nil` は raise のため到達されない。doc(notify.nim:317-319)は「接続は使用不可、close して再オープンせよ」と定めるが、呼び出し側がその通りに `close()` すると、`close():453` で `listenTask != nil and not finished` が真となり、再度 `listenReconnectStopWaitMs`(既定10秒)の有制限待ちに入る。orphan ポンプがまだ `connect()` 内ブロック中なら合計最大約20秒。 +- **再現条件**: asyncdispatch。ポンプがブロッキング `connect()` 内で停車 → `stopListening()` がタイムアウトで `PgTimeoutError` → 呼び出し側が doc 通り `close()` → 更に最大10秒待機。 +- **根拠**: + ```nim + # notify.nim:351-363 — raise が :366 より前に脱出 + except AsyncTimeoutError: + preserveStopFlag = true + conn.state = csClosed + conn.failNotifyWaiter() + raise newException(PgTimeoutError, ...) + ... + conn.listenTask = nil # :366 — timeout 経路では到達不可 + ``` + ```nim + # lifecycle.nim:453-466 — nil でない listenTask を再検出 + if conn.listenTask != nil and not conn.listenTask.finished: + ... + await pump.wait(milliseconds(listenReconnectStopWaitMs)) + ``` +- **検証**: 検証エージェントが全5点(raise 脱出・close 再待機・累計20秒・到達可能性・本コミット導入)をコードで**確認**。ハングではなく有界(最大20秒)である点から Low。 +- **提案**: `AsyncTimeoutError` ハンドラ内で raise 前に `conn.listenTask = nil` を実行する(orphan の Future はイベントループが保持し `reconnectInPlace:78` の flag チェックで自壊するため追跡不要)。または `close()` 側で `csClosed` かつ flag セット済みならポンプ待機をスキップする。 + +--- + +## 確認したい点 + +1. **`listenStopRequested` は公開フィールド(`*` 付き)だが、事後条件が変更された**。修正前は `close()`/`stopListening()` 復帰後に必ず false だったが、修正後は orphan 時に true のまま残り得る(`lifecycle.nim:472-473`、`notify.nim:324-326`)。リポジトリ内の参照はライブラリ本体とテストのみだが、外部ユーザーがこのフィールドを直接読み書きする想定はあるか。無いなら意図的内部配線として実害なし。あるなら新しい doc(types.nim:261-264)の警告(live orphan 中にクリアすると復活バグ再発)の周知が必要。**何が分かれば確定できるか**: 公開 API としてこのフィールドへの直接アクセスを想定しているか否か。 + +2. **orphan ポンプが `connect()` 内で永久ブロックする場合のリソースリーク**。サーバが TCP accept 後に PG ハンドシェイクを一切返さず、かつ `config.connectTimeout` 未設定(既定: 無制限)の場合、orphan は `connectToHost` 内で永久にブロックし、Future・新ソケット fd・`conn` 参照がイベントループに残る。修正前は `close()` 自体が永久ハングしていたため既存の asyncdispatch 制約のトレードオフ(ハング vs リーク)だが、orphan 経路で `connectTimeout` を強制するかは設計判断。**何が分かれば確定できるか**: 永久 orphan を許容するか、connect 経路にハンドシェイク全体のタイムアウトがあるか。 + +3. **chronos 側の `reconnectInPlace:78` チェックがテストで行使されない**。このチェックは `when` でガードされず両 backend に有効だが、新規テスト2〜4のアサーションは `when hasAsyncDispatch` でガード(tests/test_listen_reconnect.nim:752,780,874,961)。chronos の `close()` は `cancelAndWait` で connect を中断するため :78 に到達せず、chronos で :78 に到達する経路(stopListening 中に connect が正常完了する競合)の決定論的テストは存在しない(`notify.nim:245` が第二の安全網として機能)。**何が分かれば確定できるか**: :78 を「asyncdispatch 専用でない防御」として意図しているか、chronos 側カバレッジは不要か。 + +4. **指摘2(通常パスエラーの復活レース)への対応方針**。復活レース自体は既存だが、本コミットの目的はまさに「再接続レースでの復活を止める」であり、同族パスが未修正のまま新 invariant doc が追加されている。このパスも修正対象に含める意図か、それとも doc を狭めて scope 外を明示するか。**何が分かれば確定できるか**: 通常パスエラー分岐の flag 保持を修正する予定があるか。 + +--- + +## レビュー範囲の申告 +- **読んだファイル**: `async_postgres/pg_connection/{lifecycle,notify,types,buffer_io}.nim`、`async_postgres/{pg_errors,pg_pool,async_backend,pg_advisory_lock}.nim`(tracer 慣習確認)、`tests/{test_listen_reconnect,mock_pg_server}.nim`、`README.md`、`async_postgres.nimble`、`.github/workflows/*`。文脈構築・レビュー・検証で計20のサブエージェントを使用(9観点+検証6+文脈5)。 +- **参照した明文の規約**: AGENTS.md/CLAUDE.md/CONTRIBUTING.md は**存在しない**。実質強制は nph フォーマッタ(`.github/workflows/nph.yml`)と `nimble test` 両バックエンド(`async_postgres.nimble`)のみ。観点9エージェントが nph clean(4 files unchanged)と両バックエンド共通定義(async_backend.nim)を確認。 +- **既存コードから推定した慣習**(観点9-b の判断根拠。推定誤りなら訂正ください): + - teardown で飲み込んだエラー/不可視状態は tracer へ流す — 観測: `buffer_io.nim:589-613`(closeTransport が5箇所の closeWait 失敗を `fireTransportCloseError` へ)、`types.nim:900-921`(fireCleanupSkipped / tracer doc)、`pg_advisory_lock.nim:412,414`、`pg_pool.nim:254-260`(reportCloseError「tracing is the only signal operators have」)。Medium 1 の根拠。 + - `PgTimeoutError` は「`PgConnectionError` サブタイプ、毒化(csClosed)して raise」の意味論 — 観測: `simple_query.nim:226,244-248`(awaitOrInvalidate)、`pg_pool.nim:2065,2072`。本コミットの stopListening 使用はこれと整合。 + - `cancelAndWait` は asyncdispatch で no-op、確立慣習は `when hasChronos` ガードまたは協調フラグ+deadline — 観測: `lifecycle.nim:474-475`、`pg_client/pipeline.nim:392-395`、`pg_pool.nim:2241`。`abortListenTask`(notify.nim:297)の裸 cancelAndWait は既存の逸脱(本コミット対象外)。 + - module-level 状態は最小・非公開+doc 化 — 観測: `notify.nim:28`(listenBackoffTickMs 非公開 const)、`types.nim:735-741`。可変 `var` のグローバルは `listenReconnectStopWaitMs` が初だが、9-b の「3箇所以上の一貫性」を満たさず指摘には至らなかった。 + - README は例外型を一切文書化せず、doc コメントが一次ソース — 観測: README.md 全体に PgError 系言及ゼロ、README.md:170-172 で生成ドキュメントへリンク。V6-F1(README 欠落)反証の根拠。 +- **十分に検証できなかった領域とその理由**: (a) asyncdispatch の実際のスケジューリング順序に依存するレース(指摘2の通常パス)は、構造違反は確定したが実行時発現の頻度は静的解析では確定不能。(b) orphan の永久リーク(確認したい点2)は `connectTimeout` 未設定時の外部サーバ挙動に依存し、ビルド/実行環境で実証していない。(c) chronos での `reconnectInPlace:78` 到達性(確認したい点3)は決定論的テストが無く未検証。(d) Nim コンパイル/テストは実行していない(読み取り専用レビューのため)。nph 準拠は観点9エージェントが nph 実行で確認済み。 +- **検出後に検証で破棄した指摘の件数**: 2 件(観点6「README/examples 未更新」=ドキュメント慣習により反証、観点7「flag クリア条件の分散」=集約は非現実的・invariant は既に doc に集約済みで好み/細部に該当し反証)。加えて観点5「PgTimeoutError surface」は検証の結果「互換性維持(サブタイプで既存 catch が透過追随)」と確定し指摘としては不採用、観点2の復活レース本体は既存問題と確定したため doc 矛盾(Low 2)へ再スコープ。 diff --git a/reviews/listen_reconnect_race_review.md b/reviews/listen_reconnect_race_review.md new file mode 100644 index 00000000..8c4415e8 --- /dev/null +++ b/reviews/listen_reconnect_race_review.md @@ -0,0 +1,114 @@ +# コードレビュー結果 + +**対象**: 最新コミット `5fd9e37` (4 ファイル / +385 −33 行) +**ブランチ**: fix/listen-close-reconnect-race +**変更の要約**: asyncdispatchでlistenポンプが自動再接続のblocking `connect()`内に詰まっているとき、`close()` が無界にハングする問題と、naiveな有界waitが `reconnectInPlace` のnewConn無条件graftで接続を復活させてしまう問題を修正。`close()`/`stopListening()` に有界waitを導入してタイムアウト時にポンプをorphan化し、`listenStopRequested` をset維持、`reconnectInPlace` が `connect()` 直後に同フラグをチェックしてnewConnを破棄する。`stopListening` はorphanタイムアウトで `PgError` を投げる。 + +## 修正ステータス(追記) + +Medium 1・2 を同ブランチで対応済み(未コミット)。両 backend で `test_listen_reconnect` / `test_e2e_listen` / `test_pool` 全緑、各修正について disable→FAIL→restore→OK で regression detection 実証済み。詳細は各 Issue 末尾を参照。 + +- **Medium 1**: 修正済み。`stopListening` orphan-raise 前に `conn.state = csClosed` と `conn.failNotifyWaiter()` を追加。pending waiter は即解放、後続 `waitNotification` は `checkListenAlive` の csClosed ガードで拒否。 +- **Medium 2**: 修正済み。`PgError` → `PgTimeoutError`(`PgConnectionError` サブタイプ)に変更。`listen*`/`unlisten*`/`stopListening*` の docstring も更新。 +- **Q1 / Q2**: 上記 Medium 1・2 に沿って解消。設計意図として「close して開き直す」契約を明示(doc に追記)、pending waiter は他終了パスと対称に解放。 +- **Q3(silent orphan の tracer 通知)**: 未対応。設計判断が必要。 +- **Q4(README/examples 未反映)**: 未対応。 +- **Q5(chronos カバレッジ)**: 既存新規テスト(graft/discard 判定)は `when hasAsyncDispatch` 無しで chronos でも走行・アサート済み。追加不要と判断。 + +--- + +## Critical + +指摘なし。 + +## High + +指摘なし。 + +## Medium + +### 1. stopListeningのorphan-raiseパスがpending/後続の `waitNotification` waiterを解放せずハングさせる【修正済み】 + +- **場所**: `async_postgres/pg_connection/notify.nim:349-361`(解放漏れ)、影響先 `notify.nim:420-456` +- **何が起きるか**: reconnect中のポンプに対して `stopListening` がタイムアウトしorphan化して `PgError` を投げると、pending(またはraise後に発行された)`waitNotification(timeout=ZeroDuration)` が永久ブロックしうる。doc契約通り `close()` すれば解放されるが、(a) `PgError` 受信から `close()` 実行までの間、別タスクのwaiterは契約を守っていても一時ハングする、(b) 回復可能エラーを返すstopListeningパスの中で**唯一**waiterをinlineでfailしない非対称がある。 +- **再現条件**: asyncdispatch。`listen` 後にサーバ切断→ポンプがreconnectのblocking `connect()` で停車(`listenReconnecting=true`)→別タスクが `waitNotification(ZeroDuration)` で待機、またはraise後に発行→`stopListening` が `listenReconnectStopWaitMs` でタイムアウトし `PgError` →呼び出し側が即 `close()` しない→waiterハング。 +- **根拠**: + + ```nim + # notify.nim:349-361 + except AsyncTimeoutError: + preserveStopFlag = true + raise newException( + PgError, + "stopListening: listen pump did not stop within " & $listenReconnectStopWaitMs & + " ms while reconnecting", + ) + except CatchableError: + await conn.abortListenTask() + conn.listenTask = nil # 360 — raiseで到達しない + conn.failNotifyWaiter() # 361 — raiseで到達しない + ``` + + raiseは `listenTask = nil`(360) と `failNotifyWaiter()`(361) の手前で発生する。raise直後、`conn.state` は `reconnectInPlace:70` が設定した `csConnecting` のまま、`listenTask` は生存。この状態で `waitNotification` を呼ぶと、`checkListenAlive`(420-427) は `csClosed`/`listenError` しか見ず `csConnecting` を検出できないため素通りし、442の `listenTask == nil or finished` も偽のため、446でwaiterが生成され454で永久ブロックする。orphanポンプは `connect()` 解除後 `return`(255/271) するだけで `notifyListenDeath`/`failNotifyWaiter` を呼ばない。`notifyWaiter.fail` を行うのは notify.nim:158, 305 と lifecycle.nim:488(close) のみで、orphan後に解放できるのは `close()` だけ。他のstopListening終了パス(早期return:325、reconnect分岐:361、normal分岐:378)は例外なく `failNotifyWaiter` を呼ぶ。 +- **検証**: 中立検証エージェントが制御フロー(1)-(8)をすべてコードで確認(**確認**)。永久ハングはdoc契約違反(非close)時のみだが、契約遵守時でも `close()` までの一時ハングが存在し、回復可能エラーを返す唯一のパスとしてwaiter即時failを欠く非対称は契約では正当化されない実缺陷と判定。 +- **提案**: `AsyncTimeoutError` 分岐でraise前に `conn.failNotifyWaiter()` を呼ぶ(orphanは通知をdispatchしないため解放は安全で、他の全終了パスと整合する)。あるいはraise前に `conn.state = csClosed` へ落とせば `checkListenAlive` が機能し、早期return部の `csClosed` ガード(323)とも整合する。いずれにせよこの振る舞いをテストで固定する(観点8参照)。 + +### 修正 + +提案の両方を採用。`AsyncTimeoutError` 分岐で raise 前に `conn.state = csClosed` と `conn.failNotifyWaiter()` の 2 行を追加。pending waiter は即解放、後続 `waitNotification` は `checkListenAlive` の csClosed ガードで拒否される。回帰テスト `tests/test_listen_reconnect.nim` "stopListening orphan-raise releases pending waitNotification and refuses new ones" を追加、fix 2 行のコメントアウトで FAIL することを実証済み。 + +### 2. orphanタイムアウトのエラーが裸 `PgError` で、pg_errors.nimの文書化された分類原則と自docの意味論に矛盾する【修正済み】 + +- **場所**: `async_postgres/pg_connection/notify.nim:353-357`(raise本体)、伝播先 `notify.nim:390`(listen)/`400`(unlisten) +- **何が起きるか**: `stopListening`(および公開API `listen`/`unlisten`)が新たに投げるエラーが裸 `PgError`(`PgConnectionError` のサブタイプでない)。このエラーの自docは「connection is unusable — close and reopen」(再接続が回復)と述べるが、これはpg_errors.nimが規定する「`PgConnectionError` サブタイプとして `except PgConnectionError` ループからvisibleであるべき」基準そのもの。文書化された `except PgConnectionError` 再接続イディオムで `listen`/`unlisten` を包む利用者は、この「接続使用不能」シグナルを取りこぼす。 +- **再現条件**: 呼び出し側が `listen`/`unlisten` を `except PgConnectionError` 再接続ループで囲い、asyncdispatchでポンプがblocking `connect()` 中に `stopListening` がタイムアウトする場合。 +- **根拠**: + + ```nim + # notify.nim:353-357 + raise newException( + PgError, + "stopListening: listen pump did not stop within " & $listenReconnectStopWaitMs & + " ms while reconnecting", + ) + ``` + + pg_errors.nim:15-19は「再接続が正しい回復なら、そのエラーは `PgConnectionError` のサブタイプであり `except PgConnectionError` ループからvisibleでなければならない」と規定。既存のタイムアウトサイトは例外なく `PgTimeoutError`(`PgConnectionError` サブタイプ)を投げる: `simple_query.nim:226`、`notify.nim:452`(waitNotification)、`pg_pool.nim:2065/2072/2152/2155`。listenポンプ永久死専用の `PgListenError` も `PgConnectionError` サブタイプ(pg_errors.nim:108-112)。一方 `notify.nim:316-317` の自docは「unusable — close and reopen」と述べ、これはまさに `PgConnectionError` 相当の意味論。意図的に `PgConnectionError` 外に置かれた `PgStateError`(pg_errors.nim:57-67)は「再接続は無意味」という**明示的根拠**付きだが、新エラーはその逆の意味論で、同先例は援用不能。 +- **検証**: 中立検証エージェントが**確認**。事実関係をすべて裏付け、「意図的除外の根拠は文書化されておらず、コードの配置と著者自身のdocテキストが内部矛盾している」と判定。代償は契約/文書レベルで実在、現状のin-repo呼び出し側(listen/unlistenを `except PgConnectionError` で包む箇所はゼロ)レベルでは理論的。 +- **提案**: このエラーを `PgTimeoutError`(意味的に「タイムアウトで接続を閉じるべき」)または `PgListenError` 等 `PgConnectionError` のサブタイプとして投げ、文書化された再接続ループからvisibleにする。裸 `PgError` を意図的に選ぶなら、pg_errors.nimの階層docに「このlisten停止タイムアウトが `PgConnectionError` でない理由」を追記して内部矛盾を解消する。 + +### 修正 + +`PgError` → `PgTimeoutError` に変更(`PgConnectionError` のサブタイプ、他のタイムアウトサイト `simple_query.nim:226`, `notify.nim:452`, `pg_pool.nim` と整合)。`stopListening*` / `listen*` / `unlisten*` の docstring も `PgTimeoutError (a PgConnectionError subtype)` に更新、`except PgConnectionError` 再接続ループから可視化。回帰テストも `PgTimeoutError` および `e of PgConnectionError` を検証。 + +## Low + +指摘なし。 + +--- + +## 確認したい点 + +1. **(指摘1に関連)orphan-raiseで `failNotifyWaiter` を呼ばないのは意図か欠落か。** `failNotifyWaiter` のdoc(notify.nim:301 "when the pump has stopped")の字面条件(raise時点ではポンプ未停止)を根拠にした設計か、それとも「close and reopen」契約による `close()` 任せを前提とした取りこぼしか。契約違反(closeせず参照を捨てるだけ)時の永久ハングを許容する設計意図かを確認したい。 + +2. **(指摘2に関連)裸 `PgError` の選択は意図的な設計判断か。** 「再接続ではなくcloseして開き直すことを強制したい」等の意図があればdocに明記すると、pg_errors.nimの階層docと整合する。`PgTimeoutError`/`PgListenError`/`PgConnectionError` のいずれが意図に合うか確認したい。 + +3. **orphan化という新しい失敗モードが完全にsilent。** `close()` のasyncdispatch orphanパス(lifecycle.nim:468-469 `except AsyncTimeoutError: discard`)と、その後の自己清掃退出(notify.nim:245-255、`reconnectCallback`/`notifyListenDeath` を意図的にスキップ)は、tracer・コールバック・ログのいずれも出さない。本リポジトリには「otherwise silentなswallow/teardownイベントはtracerへ流す」という強い文書化慣習がある(buffer_io.nim:592-613 `fireTransportCloseError`、pg_pool.nim:259-260 `onPoolCloseError`、types.nim:919-921 "tracing is the only signal operators have")。本コミットが導入する新しい失敗モードに対して、運用追跡性を周辺コード同等にする意図があるか確認したい。 + +4. **README.md / examples/listen_notify.nim が新契約を未反映。** 本コミットで追加された「asyncdispatch限定で `listen`/`unlisten`/`stopListening` がorphan-timeoutの `PgError` を投げ、接続はunusable→close and reopen」という契約に、README:109-113のLISTEN/NOTIFY回復モデルもexamples/listen_notify.nim(try/except無し、PgErrorは `waitFor main()` まで伝播してプロセスを落とす)も言及していない。README:129のasyncdispatch一般警告で十分という判断か確認したい。 + +5. **chronos側のカバレッジ。** 新規テスト2/3のアサーションは `when hasAsyncDispatch` で囲まれchronosでは実質何も検証しない(tests/test_listen_reconnect.nim:745-747に意図明記)。一方 `reconnectInPlace` のpost-connect stop-check(notify.nim:78-86)はバックエンド非依存コード。chronosではcancellationで閉じる設計のはずだが、この非依存パスにchronos側カバレッジが不要か念のため確認したい。 + +--- + +## レビュー範囲の申告 + +- **読んだファイル**: `async_postgres/pg_connection/notify.nim`(全文)、`lifecycle.nim`(close周辺 400-519)、`types.nim`(全文)、`buffer_io.nim`(closeTransport/isConnected 585-724)、`simple_query.nim`(checkReady 63-82)、`pg_errors.nim`(全文 1-115)、`tests/test_listen_reconnect.nim`(1-120+diffの新規部分)、`tests/all_tests.nim`、`tests/config.nims`、`.github/workflows/test.yml`、`nph.yml`、`async_postgres.nimble`。サブエージェント経由で `pg_pool.nim`、`pg_connection.nim`、`async_backend.nim`、`mock_pg_server.nim`、`examples/listen_notify.nim`、`README.md`、`pg_largeobject.nim`、`test_e2e_listen.nim`、`test_pool.nim` を参照。 +- **参照した明文の規約**: なし(AGENTS.md / CLAUDE.md / CONTRIBUTING.md は存在しない。CI設定 test.yml / nph.yml のみ確認)。 +- **既存コードから推定した慣習**(観点9-bの判断根拠。推定が誤っていれば訂正いただきたい): + - タイムアウトのエラー型は `PgTimeoutError`(`PgConnectionError` サブタイプ): `simple_query.nim:226`、`notify.nim:452`、`pg_pool.nim:2065/2072/2152/2155`。生の `AsyncTimeoutError` は単一ホストconnectTimeout(`lifecycle.nim:657-658`、意図的・文書化)と `buffer_io.nim:207/234`。**パターンが割れている領域**として記録(指摘2は9-bではなく互換性/設計整合として扱った)。 + - 兄弟モジュールが内部シンボルを使う場合はヘルパーをexportして `{.all.}` を回避: `types.nim:801`。`{.all.}` はテストが特定サブモジュールを直接import: `pg_connection.nim:43-46`。 + - otherwise silentなswallow/teardownイベントはtracerへルーティング: `buffer_io.nim:592-613`、`pg_pool.nim:259-260`、`types.nim:485-489, 919-921`。 + - docコメントで「何が伝播するか」を明記する慣習: `notify.nim`/`lifecycle.nim` 各所(本コミットのdoc追記もこれに沿う)。 +- **十分に検証できなかった領域とその理由**: 本タスクは読み取り専用に制約されているため、`nimble test` / `nim c` によるコンパイル・型検査・テスト実行は未実施(実機挙動は未検証)。新規テストはモックサーバベースかつ `when hasAsyncDispatch` ガード内のため、chronosバックエンドでの実挙動と、実PostgreSQLに対するblocking `connect()` の実際の持続時間に依存する部分(orphan発生の実頻度)は検証できていない。 +- **検出後に検証で破棄した指摘の件数**: 1 件(生産コードの `import types {.all.}` — types.nim:41-42で著者意図が明記され、結合面拡大の代償が理論的(使用非公開シンボルは `listenReconnectStopWaitMs` の1件のみ)のため反証・破棄)。 diff --git a/reviews/pg_advisory_lock_review.md b/reviews/pg_advisory_lock_review.md new file mode 100644 index 00000000..b0e5facc --- /dev/null +++ b/reviews/pg_advisory_lock_review.md @@ -0,0 +1,102 @@ +# pg_advisory_lock.nim レビュー調査結果 + +## 指摘1: withAdvisoryLockCore の body 内 return/break/continue で unlock がスキップされる (line 390-419) + +**判定: 妥当 (Medium) / doc 追記推奨、コード修正は asyncdispatch 制約で困難** + +### 問題 + +`withAdvisoryLockCore` は template として展開されるため、body 内の `return` は呼び出し元 proc を即座に抜け、後続の unlock コードに到達しない。`break`/`continue` が外側ループをターゲットする場合も同様。 + +```nim +# 展開後の実質的なコード: +await c.advisoryLock(k) +var bodyErr: ref CatchableError = nil +try: + # body ← ここで return すると以下全部スキップ +except CatchableError as e: + bodyErr = e +# unlock コード (到達しない) +``` + +line 361-365 のコメントで `finally` + `await` が asyncdispatch 下で例外を握り潰すため `try/except` を選んだと説明あり。制約は認識済みのトレードオフ。 + +### 緩和要因 + +- **プール接続**: `sessionLockDirty` フラグにより、プール返却時に `pg_advisory_unlock_all` が実行される (`pg_pool.nim:325-341`)。実害はほぼなし。 +- **スタンドアロン接続**: コネクションクローズまでロック残留。ただしサーバ側でセッション終了時に自動解放。 +- **`withAdvisoryLockXact`**: トランザクション終了時に自動解放されるためこの問題は存在しない。 + +### doc の問題 + +"then release the lock (even on exception)" は例外のみ言及しており、`return` 等の非例外制御フローでスキップされることに未言及。 + +### 修正方針 + +doc に「body 内の `return`/`break`/`continue` は unlock をスキップする」旨の制約を追記する。コード修正は asyncdispatch の制約で困難。body を `block` でラップすればトップレベルの `break` は捕捉できるが、外側ループ向けの `break`/`continue`/`return` は防げない。 + +--- + +## 指摘2: lock と unlock に同じ timeout を再利用 (line 379-407) + +**判定: 妥当 (Low) / doc 追記で十分** + +### 問題 + +lock 取得と unlock の両方に同じ `t` を渡している。lock がタイムアウトぎりぎりで成功した場合、unlock にも再度フルの `t` が与えられるため、実効的な最大待ち時間は `2 * t` になる。 + +commit 5023e9f で意図的に unlock へ timeout を伝播させた変更であり、設計判断としては理解できる。 + +### 修正方針 + +doc に「実効最大待ち時間は `2 * timeout` になり得る」旨を追記すれば十分。コード変更不要。 + +--- + +## 指摘3: ensureXactScope のエラーメッセージが tsInFailedTransaction で不正確 (line 119-124) + +**判定: 妥当 (Very Low) / 任意の修正** + +### 問題 + +`tsInFailedTransaction`(直前のクエリエラーで transaction が abort 状態)の場合も "requires an active transaction" と表示される。トランザクション自体は存在しており失敗状態にあるのが実態のため、デバッグ時に誤解を招く可能性がある。 + +ただしメッセージ末尾に `(txStatus: tsInFailedTransaction)` が含まれるため、実際のデバッグに支障はほぼない。 + +### 修正方針 + +任意。修正する場合は `tsInFailedTransaction` 時に "transaction is in a failed state" のようなメッセージに分岐させる。優先度は極めて低い。 + +--- + +## 指摘4: unlockSessionLock の counter guard と mixed raw/typed usage (line 112-113) + +**判定: 既知・文書化済みの制約 / 対応不要** + +### 問題 + +raw SQL で取得したロックを typed API で解放すると、サーバは `true` を返すため counter が decrement されるが、そのロックは counter に加算されていない。他に typed ロックがあればそれの分を「盗む」形になり、counter が実態より少なく報告される。 + +### 文書化状況 + +- module doc (line 42-51) で明確に文書化済み +- `types.nim:302-314` でも説明あり +- `sessionLockDirty` フラグがリーク検知の実判定に使われており、安全性への影響なし +- テスト (`test_advisory_lock.nim:658-659, 790-813`) でもこの挙動を検証済み + +### 修正方針 + +対応不要。設計として承知の上であり、文書化・テスト済み。 + +--- + +## 総括 + +| # | 重大度 | 対応要否 | +|---|--------|----------| +| 1 | Medium | doc 追記推奨(コード修正は asyncdispatch 制約で困難) | +| 2 | Low | doc 追記で十分 | +| 3 | Very Low | 任意 | +| 4 | — | 対応不要(既知・文書化・テスト済み) | + +実運用上のリスクは、プール経由で使う限りほぼゼロ。スタンドアロン接続で body 内に `return` を書くケースだけが注意対象。 diff --git a/reviews/pg_copy_review.md b/reviews/pg_copy_review.md new file mode 100644 index 00000000..604c68fa --- /dev/null +++ b/reviews/pg_copy_review.md @@ -0,0 +1,64 @@ +# pg_client/copy.nim レビュー調査結果 + +明確なバグは検出されず。プロトコル同期・エラーハンドリング・バッチ分割のロジックはいずれも正しく実装されている。以下、軽微な所見2点の詳細調査。 + +--- + +## 指摘1: copyInStreamImpl の送信サイズ上界がコールバック依存 (line 281, 292, 307) + +**判定: バグではない / ドキュメント追記推奨 (Low)** + +### 問題 + +`copyInRawImpl` (line 115) は `maxPayload = copyBatchSize - 5` で入力を事前スライスし、`encodeCopyData` 1回あたり最大 `copyBatchSize` (256KB) しか sendBuf に追加しない。sendBuf は常に 256KB 以下に保たれる。 + +一方 `copyInStreamImpl` (line 292) はコールバックの返す `chunk` をそのまま `encodeCopyData(conn.sendBuf, chunk)` に渡し、スライスしない。sendBuf はフラッシュ閾値未満で蓄積されるため、最大サイズは: + +``` +(copyBatchSize - 1) + 5 + chunk.len ≈ 256KB + chunk.len +``` + +コールバックが 10MB のチャンクを返せば sendBuf は約 10.25MB まで成長し、1回の `sendBufMsg` で送信される。メモリピークは `chunk + sendBuf へのコピー ≈ 2×chunk.len`。 + +### ガードの範囲 + +`encodeCopyData` (pg_protocol.nim:761) は `data.len > maxInt32Len - 4` (約2GB) で `ValueError` を送出。これは line 299 の `except CatchableError` で捕捉され `callbackError` → CopyFail パスに正しく流れる。プロトコルの整合性は保たれている。 + +### doc の不備 + +line 424 の "bounded by one batch of extra streaming" はサーバーアボート検出の遅延に関する記述であり、送信サイズの上界には言及していない。 + +### 修正方針 + +doc に送信サイズ上界がコールバックのチャンクサイズに依存する旨を追記する。`copyInRawImpl` のようにチャンクを内部でスライスすれば上界を 256KB に固定できるが、現状はコールバック側の責任とする設計。コード変更は任意。 + +--- + +## 指摘2: copyIn(seq[seq[byte]]) のピークメモリ (lines 227-235) + +**判定: バグではない / 設計上のトレードオフとして認識済み (Low)** + +### 問題 + +全チャンクを連結して単一の `seq[byte]` を生成するため、データフローは: + +``` +元のチャンク群 (data) → combined へ全コピー → copyInRawImpl 内で sendBuf へ再コピー +``` + +ピークメモリ: `data 全体 + combined (同サイズ) + sendBuf (≤256KB)` ≈ **2×データサイズ + 256KB** + +### 一貫性 + +`openArray[byte]` オーバーロード (line 205) も `let dataCopy = @data` で非同期境界前にコピーしており、パターンとして一貫している。async proc に `openArray` を渡せないための必須コピー。 + +### 回避可能性 + +`copyInStream` を内部で使えば連結コピーを回避できるが: + +- `copyInStreamImpl` はコールバックエラー時に CopyFail を送るが、`copyInRawImpl` は送信エラーとして扱う。セマンティクスが微妙に変わる。 +- doc (line 225) は "Concatenates chunks and delegates" と正直に記述しており、大規模データには `copyInStream` を使うのが想定パス。 + +### 修正方針 + +現状維持で問題なし。`copyInStream` への誘導が doc にあれば十分。 diff --git a/reviews/pg_pool_review.md b/reviews/pg_pool_review.md new file mode 100644 index 00000000..b5747622 --- /dev/null +++ b/reviews/pg_pool_review.md @@ -0,0 +1,83 @@ +# pg_pool.nim レビュー調査結果 + +## 指摘1: ~~resetSession の冗長 close パス (line 354-358) + +### 実行フロー + +1. `resetSession` の `CatchableError` ハンドラ (line 354-358) が `tracedClose(conn)` → `conn.close()` を呼び、`conn.state = csClosed` に設定 +2. `resetSessionAndRelease` の `finally: conn.release()` → `releaseCore` (line 673) で `conn.state != csReady` が true → `closeNoWait(conn)` が再度呼ばれる +3. `closeNoWait` は `metrics.closeCount.inc` + 新たな `doClose()` タスクを `asyncSpawn` + +`conn.close()` が2回呼ばれ、不要なバックグラウンドタスクが1つ生成される。 + +一方 `CancelledError` パス (line 347-353) は `conn.state = csClosed` だけ同期設定し、close は `releaseCore` に委譲している。コメント (line 350-351) も「calling it here as well would double-count metrics」と明記。 + +### 修正方針 + +CatchableError ハンドラを CancelledError パスと対称にし、`conn.state = csClosed` のみ設定する。機能的バグではないが、冗長なタスク生成とソケット close の二重呼び出しを解消できる。 + +--- + +## 指摘2: asyncdispatch の cancelAndWait no-op と close() の競合 (line 2230-2231) + +**判定: 妥当 (Low / ドキュメント改善) / コード変更不要** + +### 問題 + +`async_backend.nim:193-204` で asyncdispatch の `cancelAndWait` は `discard` のみ。`close()` 中にメンテナンスループが replenish フェーズ (`await allFutures(connectFuts)`, line 583) の場合: + +- `close()` は `pendingBackgroundTasks` の drain ループ (line 2286-2300) を抜ける +- その後メンテナンスループが connect 完了 → `pool.closed` 検知 → `closeNoWait(conn)` で新タスク追加 +- このタスクは `close()` 返回後に完了する + +`close()` の drain ループは snapshot-and-clear 方式だが、最終チェック後に追加されたタスクは取りこぼす。コネクション自体は `asyncSpawn` で最終的に close されるためリークにはならないが、`close()` 返回時点でのリソース解放保証が asyncdispatch では弱くなる。 + +### 修正方針 + +`close()` の doc comment に asyncdispatch でのこの振る舞いを追記するのが適切。コード変更は不要。 + +--- + +## 指摘3: dispatchHomogeneous の CancelledError 捕捉 (line 1274-1279) + +**判定: 妥当だが現状実害なし (Low) / 防御的修正 or コメント** + +### 問題 + +`dispatchBatchImpl` は `asyncSpawn` (line 1367) で fire-and-forget され、`pendingBackgroundTasks` に登録されない。したがって: + +- `close()` の `cancelAndWait(f)` (line 2298) の対象外 +- 外部からこのタスクをキャンセルするパスが現状存在しない + +`except CatchableError: break` (line 1278) が `CancelledError` を飲み込むのは事実だが、キャンセルの発生源がないため発火しない。将来 `dispatchBatchImpl` を `pendingBackgroundTasks` で管理するようになると、キャンセル伝播が壊れる。 + +### 修正方針 + +防御的に `except CancelledError: raise` を `except CatchableError` の前に追加するか、現状維持でコメントを残すか。優先度は低い。 + +--- + +## 指摘4: splitBatchBudget の cap 超過 (line 1186-1187) + +**判定: 意図的 (Info) / 対応不要** + +### 問題 + +`cap == 1` のとき finite=1, unlimited=1 で合計2が cap を超える。コメント (line 1183-1184) で意図的と明記済み。 + +`maxSize == 1` + mixed timeout では、2つの `dispatchHomogeneous` が並行に `acquire()` を試み、一方が `acquireTimeout` (デフォルト30秒) ブロックされてから `except CatchableError: break` で1コネクションに縮退する。この遅延は `maxSize` が極端に小さいプールのエッジケースであり、実用上は `maxSize >= 2` が前提。 + +### 修正方針 + +不要。必要なら doc comment に `maxSize == 1` 時の遅延可能性を追記する程度。 + +--- + +## まとめ + +| # | 判定 | 推奨アクション | +|---|------|---------------| +| 1 | 妥当 | CatchableError ハンドラを `conn.state = csClosed` のみに変更 | +| 2 | 妥当 | `close()` の doc comment に asyncdispatch の制限を追記 | +| 3 | 妥当 (潜在) | 防御的 `except CancelledError: raise` 追加 or コメント | +| 4 | 意図的 | 対応不要 | diff --git a/reviews/pg_protocol_review.md b/reviews/pg_protocol_review.md new file mode 100644 index 00000000..d8c63ccc --- /dev/null +++ b/reviews/pg_protocol_review.md @@ -0,0 +1,79 @@ +# pg_protocol.nim レビュー指摘事項 + +## 1. `reuseRowData` のドキュメントコメントが不正確 (中) — 対応済み + +**箇所:** `async_postgres/pg_protocol.nim:1022-1024, 1035-1039` + +**ステータス:** 04911c8 (PR #570) で main へマージ済。両オーバーロードの doc を更新し、"left intact" 記述と存在しない `QueryResult` 参照を削除。`rd` は valid ref のままだが `buf`/`cellIndex` (第2オーバーロードでは加えて `colFormats`/`colTypeOids`) が空になる旨を明記。 + +以下は当初レビュー内容 (記録保持用)。 + +```nim +## The old RowData (and any QueryResult still referencing it) is left intact. +``` + +### 結論: 有効 — 修正推奨 + +`RowData` は `ref object` (141行目) であるため、`reuseRowData(rd: RowData, ...)` の引数は参照のコピーであり、呼び出し元と同じオブジェクトを指す。 +`move rd.buf` / `move rd.cellIndex` は共有オブジェクトのフィールドを空にするため、呼び出し元の RowData も影響を受ける。 + +テスト (`tests/test_rowdata.nim:192-193`) がこれを裏付けている: + +```nim +check rd1.buf.len == 0 +check rd1.cellIndex.len == 0 +``` + +"left intact" という記述は誤り。古い RowData のバッファは move により空になり、以降データアクセスには使用できないことを明記すべき。 + +現時点で本番コードでの使用はなくテストのみのため、実害はないが将来の誤用リスクがある。 + +### 修正案 (当初提示) + +```nim +## Create a new RowData that takes over the old buffer's capacity via move. +## The old RowData's buf and cellIndex are drained (moved); it must not be +## used for data access afterward. +``` + +### 適用済み内容 + +```nim +## Create a new RowData that takes over the old buffer's capacity via move. +## `rd` remains a valid ref but its `buf`/`cellIndex` are emptied — callers +## still holding `rd` must not read row data through it after this call. +``` + +第2オーバーロード (line 1035) にも同趣旨のコメントを追加し、追加で move される `colFormats`/`colTypeOids` を列挙。 + +--- + +## 2. `addBindRaw` の int32 オーバーフロー (低) + +**箇所:** `async_postgres/pg_protocol.nim:658` + +```nim +buf.writeBytesAt(oldLen, paramData.toOpenArray(r.off, r.off + r.len - 1)) +``` + +### 結論: 理論上有効・実害なし — 任意の防御的修正 + +`r.off`, `r.len` は `int32` (618行目)。`r.off + r.len - 1` は int32 演算で計算される。 +650行目の境界チェックは int64 を使用しているため検証は正しく通るが、658行目のインデックス計算でオーバーフローし得る。 + +```nim +# 650行目: 正しい (int64) +if r.off.int64 + r.len.int64 > paramData.len.int64: + +# 658行目: int32 でオーバーフローし得る +paramData.toOpenArray(r.off, r.off + r.len - 1) +``` + +発生条件: `paramData` が ~2 GiB 超 かつ `r.off + r.len > int32.high`。 +PostgreSQL の1値あたり ~1 GiB 制限、および `parseDataRowInto` 内の 2 GiB チェック (1119行目) を考慮すると実質的に発生不可能。 + +### 修正案 (任意) + +```nim +buf.writeBytesAt(oldLen, paramData.toOpenArray(int(r.off), int(r.off) + int(r.len) - 1)) +``` diff --git a/reviews/pg_replication_review.md b/reviews/pg_replication_review.md new file mode 100644 index 00000000..dfd38222 --- /dev/null +++ b/reviews/pg_replication_review.md @@ -0,0 +1,87 @@ +# pg_replication.nim レビュー + +## 指摘1: CopyDone 二重送信 + +**重大度: 中〜低** (プロトコル違反だが現行PGでは実害限定的) + +### 問題 + +`stopReplication` と recvLoop の `bmkCopyDone` ハンドラの両方が CopyDone を送信し、 +通常の `stopReplication` フローでも常に二重送信が起きる。 + +### 再現手順 (通常フロー) + +1. コールバック内で `stopReplication` 呼び出し → `pg_replication.nim:1316` で CopyDone 送信 +2. コールバック返回後、recvLoop が `replFillRecvBuf` でサーバー応答を待つ +3. サーバーが CopyDone + CommandComplete + ReadyForQuery を返す +4. recvLoop の `bmkCopyDone` ハンドラ (`pg_replication.nim:1084-1093`) が発火 → 再度 CopyDone 送信 +5. drainLoop が CommandComplete + ReadyForQuery を処理して `csReady` に遷移 + +`bmkCopyDone` ハンドラは「サーバー起因停止用」とコメントされているが、 +クライアント起因の停止でもサーバーの CopyDone 応答に対して必ず発火する。 + +### 競合シナリオ (サーバー起因停止との同時発生) + +1. サーバーが XLogData の後に CopyDone を送信 (walsender timeout, `pg_terminate_backend` など) +2. クライアントの recvLoop が XLogData を処理 → コールバック内で `stopReplication` → CopyDone 送信済み +3. コールバック返回後、recvLoop の inner while がバッファ済みのサーバー側 CopyDone を処理 +4. `bmkCopyDone` ハンドラが再度 Standby Status Update + CopyDone を送信 + +### 実害が限定的な根拠 + +- e2e テスト (`test_e2e_misc.nim:257`, `:319`) が `stopReplication` をコールバック内から呼び、 + `csReady` 遷移と後続クエリ (`identifySystem`) の成功を確認済み +- 2回目の CopyDone はサーバーが ReadyForQuery 送信後に到着するため、 + PostgreSQL は黙殺している可能性が高い + +### 残るリスク + +- プロトコル違反であり、将来の PG バージョンで拒否される可能性 +- サーバー起因停止と `stopReplication` が同時に起きた場合、2回目の CopyDone に対する + ErrorResponse が recv buffer に残留し、後続クエリを汚染する可能性 + +### 修正方針 + +`conn` 側に `clientSentCopyDone` フラグを追加し、`stopReplication` 送信時に true、 +`bmkCopyDone` ハンドラで true なら CopyDone 再送をスキップする。 +`stopReplication` は `runReplicationStream` の外から呼ばれるため、フラグは `conn` 側に持たせる必要がある。 + +--- + +## 指摘2: `readReplicationSlot` が `outputPlugin` を設定しない + +**重大度: 低** (ドキュメント改善) + +### 問題 + +`READ_REPLICATION_SLOT` の返却列は `slot_type, restart_lsn, restart_tli` の3列 +(`pg_replication.nim:657` コメント参照)。`output_plugin` は含まれないため、 +`ReplicationSlotInfo.outputPlugin` (`pg_replication.nim:62`) は常に空のまま。 + +`createReplicationSlot` 経由でのみ値が入るが、`readReplicationSlot` の doc コメントに +その旨の記載がなく、誤解を招く可能性がある。 + +### 修正方針 + +`readReplicationSlot` の doc コメントに「`outputPlugin` は設定されない」旨を追記する。 + +--- + +## 指摘3: `decodeCreateSlotRow` が行数を検証しない + +**重大度: 低** (公開APIのガード不足) + +### 問題 + +`pg_replication.nim:600-607` の `decodeCreateSlotRow` は `*` で公開されているが、 +`initRow(qr.data, 0)` で0行目を直接参照し、`rowCount` チェックを行わない。 + +- 唯一の内部呼び出し元 `createReplicationSlot` (`:625-626`) は `rowCount == 0` を事前にチェック済み +- テスト (`test_replication.nim:753`) は常に `rowCount: 1` で構築 + +空の `QueryResult` を外部から渡すと不正アクセスになる。 + +### 修正方針 + +- **private 化** (推奨): 外部呼び出しの意図が見当たらないため、`*` を外す +- **ガード追加**: `if qr.rowCount == 0: raise ...` を先頭に追加 diff --git a/reviews/rest_review.md b/reviews/rest_review.md new file mode 100644 index 00000000..2591fb3e --- /dev/null +++ b/reviews/rest_review.md @@ -0,0 +1,14 @@ +ファジングの空白 + test_protocol_fuzz.nim はプロトコルのみ。encoding/decoding, DSN, SQLパース, hstore/range バイナリにはファズテストが存在しない。 + +構造的問題 +- test_types.nim が9062行/1142件で巨大化。encoding/decoding/ranges の境界が曖昧で、ギャップが埋もれやすい +- test_sql.nim は356行/77件に対し pg_sql.nim は586行。raw文字列・コメント・?演算子の網羅性に要注目 + +優先レビュー順(テスト側) +1. test_ssl.nim — MITM・証明書検証の否定ケースが18件と薄い。split-write以外の攻撃ベクトル検証要確認 +2. test_rowdata.nim — decoding の否定テスト2件のみ。Tier 1 の decoding.nim に対する検証がほぼ不在 +3. test_fill_recvbuf.nim — buffer_io の専用テストが6件。UB/範囲外の回帰テスト不足 +4. test_types.nim — 巨大すぎてレビュー困難。encoding/decoding/ranges への分割が必要 +5. test_protocol_fuzz.nim — 良好だが、ファズ対象を types/DSN/SQL へ拡張すべき +6. test_largeobject.nim — chunkSize ハードニングの検証が23件中14件と薄い diff --git a/reviews/review-transaction.md b/reviews/review-transaction.md new file mode 100644 index 00000000..fbb2e383 --- /dev/null +++ b/reviews/review-transaction.md @@ -0,0 +1,81 @@ +# レビュー詳細調査: `pg_client/transaction.nim` + +## Issue 1: `withSavepoint` のディスアンビギュエーションが `nnkStrLit` のみ + +**重要度:** Low (コンパイルエラーになるためサイレントバグにはならない) + +**箇所:** `transaction.nim:542` + +```nim +if args[0].kind == nnkStrLit: +``` + +**問題:** +`nnkRStrLit` (`r"..."`) と `nnkTripleStrLit` (`"""..."""`) が未カバー。 +`conn.withSavepoint(r"my_sp"): body` と書くと名前ではなく timeout として解釈され、型不一致のコンパイルエラーになる。 + +**コードベース内の不整合:** +`direct.nim:239` では既に3種類すべてを処理済み: + +```nim +if sqlNode.kind in {nnkStrLit, nnkTripleStrLit, nnkRStrLit}: +``` + +**修正案:** + +```nim +if args[0].kind in {nnkStrLit, nnkTripleStrLit, nnkRStrLit}: +``` + +--- + +## Issue 2: 非リトライ版 `withTransaction` に `CancelledError` 専用ハンドラがない — 対応済み (branch `fix/withtransaction-cancel-cleanup` / f2867ac、未 PR) + +**重要度:** Informational (実用上の影響は軽微だが設計意図との非対称あり) + +**箇所:** `transaction.nim:421` vs `transaction.nim:265-268` + +**対応:** conn 版 `withTransaction` / `withSavepoint`、pool 版 `withTransaction` / +`withTransactionDeadline` / `withTransactionRetryDeadline` の各 body try に +`except CancelledError as e: raise e` を追加。`buildRollbackCleanup` / +`buildSavepointRollbackCleanup` / `buildDeadlineAwaitAndTimeout` の cleanup-cancel は +swallow して原因例外を再送出する対称形に統一。回帰テスト +`tests/test_transaction_cancel.nim` (chronos-only、3 テスト) を追加。以下は当初レビュー内容 +(記録保持用)。 + +### 動作比較 + +| パス | CancelledError 時の動作 | +|------|------------------------| +| リトライ版 (L265-268) | 専用ハンドラで即 re-raise、cleanup スキップ、`onCleanupSkipped` 発火なし | +| 非リトライ版 (L421-423) | `CatchableError` で捕捉 → `buildRollbackCleanup` 実行 → ROLLBACK がキャンセルされると L145-149 で `onCleanupSkipped(csrCleanupFailed)` 発火 + cleanup 側の CancelledError を re-raise | + +### 具体的な問題 + +1. 非リトライ版では元の例外 `e` が破棄され、cleanup 側の CancelledError に置き換わる +2. リトライ版のコメント (L266) に "skip the async cleanup (would just re-cancel)" とあり、キャンセル時は cleanup をスキップするのが設計意図 +3. 非リトライ版はこの意図に従っていない + +### 実害 + +どちらも `CancelledError` が伝播するので caller からは同じに見える。 +ただし `onCleanupSkipped` コールバックの発火有無が異なるため、モニタリングやロギングでこのコールバックを使用している場合、非リトライ版だけ余計なイベントが記録される。 + +### 修正案 + +非リトライ版 `withTransaction` にリトライ版と同様の `CancelledError` 専用ハンドラを追加: + +```nim +result = quote: + let `connSym` = `connExpr` + `connSym`.checkTxIdle() + try: + discard await `connSym`.simpleExec(`beginSql`, timeout = `txTimeout`) + `body` + discard await `connSym`.simpleExec("COMMIT", timeout = `txTimeout`) + except CancelledError as `cancelSym`: + raise `cancelSym` + except CatchableError as `eSym`: + `bodyCleanup` + raise `eSym` +``` diff --git a/reviews/review_decoding.md b/reviews/review_decoding.md new file mode 100644 index 00000000..efb3d4a9 --- /dev/null +++ b/reviews/review_decoding.md @@ -0,0 +1,61 @@ +# レビュー: `async_postgres/pg_types/decoding.nim` + +## Bug 1 (取り下げ): `parseIntervalText` の years 境界チェックは off-by-one ではない + +**当初主張 (誤り):** Nim の `div` は床除算のため `int64(int32.low) div 12 = -178956971` となり、 +`val = -178956971` がチェックを通過して `val * 12 = -2147483652` が int32.low を下回る。 + +**訂正:** Nim の `div` は **truncated division (ゼロ方向切り捨て)** であり床除算ではない。 +実測で確認 (`nim c -r`): + +``` +int64(int32.low) div 12 = -178956970 +int64(int32.high) div 12 = 178956970 +``` + +境界の一致: + +| val | check pass? | val * 12 | int32 に収まる? | +|---|---|---|---| +| -178956971 | ✗ (raise) | -2147483652 | ✗ | +| -178956970 | ✓ | -2147483640 | ✓ | +| 178956970 | ✓ | 2147483640 | ✓ | +| 178956971 | ✗ (raise) | 2147483652 | ✗ | + +check_passes と fitsI32 が境界上で完全一致しており off-by-one は存在しない。 +また 12 は `int32.high` / `int32.low` の約数ではないため +`val * 12` が `[2147483641, 2147483647]` や `[-2147483648, -2147483641]` の +値を取ることは原理的にない (すべての整数 val に対し `val * 12` は +`≤ 2147483640` か `≥ 2147483652` のいずれか)。 + +なお「積を先に計算して int32 範囲と比較」する案に単純に置き換えると、 +`val` は `accumDigit` により int64.high 近くまで到達し得るため `val * 12` が +int64 オーバーフローを起こす。安全にするには結局 `val` の int64 事前チェックが +必要で、その場合も現在のチェックと数学的に等価になる。 + +**結論:** 現行実装のままで正しい。修正不要。 + +## Bug 2 (低〜中): `parseTextArray` が閉じ引用符の欠落を黙認する + +- `decoding.nim:920` — quoted element の while ループが `i >= inner.len` で終了した場合 + (閉じ `"` がない)、そのまま `i += 1` して要素を追加している +- `{"abc}` のような不正入力が `@["abc"]` として正常にパースされてしまう +- `parseHstoreText`(`decoding.nim:501-502`)は同ケースで + `unterminated key string` を raise しており不整合 +- PostgreSQL サーバが不正リテラルを送る可能性は低いが、 + ユーザ入力がこのパスを通る場合は誤った結果を黙って返す +- 修正: ループ終了後に閉じ引用符の有無を検証する + +```nim +if i >= inner.len: + raise newException(PgTypeError, "Invalid array literal: unterminated quoted element") +i += 1 # skip closing quote +result.add(some(elem)) +``` + +## 補足: `decodeBinaryTimestamp` のデッドコード + +- `decoding.nim:122-124` — `if fracUs < 0` 分岐は到達不能 +- Nim の `mod` は正の除数に対して常に非負を返す(`div` が床除算のため) +- `unixUs mod 1_000_000` が負になることはない +- C 言語の `%` 向けパターンが混入したと思われる。バグではないが冗長 diff --git a/reviews/review_dsn.md b/reviews/review_dsn.md new file mode 100644 index 00000000..a4181fce --- /dev/null +++ b/reviews/review_dsn.md @@ -0,0 +1,42 @@ +# pg_connection/dsn.nim レビュー結論 + +## バグ + +なし。libpq のセマンティクスを正確に実装しており、エッジケースのテストカバレッジも十分。 + +## 確認済みの主要ポイント + +- `parsePort`: オーバーフロー・空白・`+` 符号の処理が正しい +- `buildHosts`: host/hostaddr 長不一致ガード、port リスト検証、デフォルト値いずれも正しい +- `openRegularFile`: `O_NONBLOCK` → `fstat` → 解除の順で TOCTOU 窓が閉じている +- `pctDecode`: 境界チェック・ゼロバイト拒否が適切 +- `parseUriDsn`: `rfind('@')` によるパスワード内 `@` 対応、IPv6 拒否も正しい +- `parseKeyValueDsn`: エスケープ処理・遅延展開が libpq 互換 + +## 軽微な所感 (バグではない) + +1. `=` のない query param (`?sslmode`) が暗黙にスキップされる (dsn.nim:626-627) + - libpq と同じ挙動だが typo に気づきにくい可能性。重大度: 極めて低 +2. Windows では `sslkey` のパーミッション検証がスキップされる + - 意図的だがセキュリティ敏感環境では認識の価値あり + +## テストカバレッジ + +### カバー済み + +- `parseDsn` / `parseUriDsn` / `parseKeyValueDsn`: 正常系・異常系ともに広範にカバー +- `parsePort`, `pctDecode`, `buildHosts`, `applyParam`: 間接的だが十分 +- `openRegularFile`: FIFO 拒否テストあり (POSIX 限定) +- `readPemFileParam`: 存在しないファイル・パーミッション違反・FIFO をカバー +- `orderedHosts` / `loadBalanceHosts`: シャッフルの性質テストまであり + +### 未カバー + +| 項目 | 該当箇所 | +|------|----------| +| `initConnConfig` の直接テスト | dsn.nim:648 | +| 空 PEM ファイルの拒否 | dsn.nim:242-243 (`result.len == 0` ガード) | +| `initConnConfig` + `sslmode=disable` + `sslcert` のバリデーション | dsn.nim:706 の `validateClientCertConfig` | +| `openRegularFile` の `fstat` 失敗パス | dsn.nim:198-200 (再現困難) | + +実質的なギャップは `initConnConfig` の直接テストと空 PEM ファイルの 2 点。 diff --git a/reviews/review_encoding.md b/reviews/review_encoding.md new file mode 100644 index 00000000..5d34c25b --- /dev/null +++ b/reviews/review_encoding.md @@ -0,0 +1,41 @@ +# レビュー: `async_postgres/pg_types/encoding.nim` + +## Bug 1 (中): `writeParamFormat(seq[byte])` のフォーマットコード矛盾 + +- `encoding.nim:1760` — フォーマットコード `0`(テキスト)を書いている +- `encoding.nim:1804` — ペイロードは生のバイナリバイトを送信 +- `encoding.nim:134` — `toPgParam(seq[byte])` は正しく `format: 1`(バイナリ)を使用 +- `addBindDirect` マクロ(`encoding.nim:1917-1919`)が `writeParamFormat` を呼ぶため、 + `queryDirect`/`execDirect` で `seq[byte]` を使うと Bind メッセージのフォーマット宣言と + ペイロードが矛盾し、PostgreSQL が `invalid input syntax for type bytea` を返す +- 既存テストに `seq[byte]` + `queryDirect`/`execDirect` の組み合わせがなく、検出されていない +- 修正: `encoding.nim:1761` の `0'i16` を `1'i16` に変更 + +## Bug 2 (低): `toPgBinaryParam(PgPath/PgPolygon)` に payload サイズガードがない [修正済] + +- `encoding.nim:1087` — `data.writeBE32(1, int32(v.points.len))` ガードなし +- `encoding.nim:1095` — `data.writeBE32(0, int32(v.points.len))` ガードなし +- 当初の指摘では「`checkPgBinLen` は配列・hstore・range・composite で使われている」 + としていたが、実際に `checkPgBinLen` を呼ぶのは `encodeBinaryArray`(L294)と + `encodeHstoreBinary`(L1160/1163/1166)の 2 encoder のみ。fixed-width element を + 扱う `buildFixedArray`(L398)は payload-level ガード + (`if payload > int32.high.int64: raise ...`)を採用しており、他 encoder が + 「全て `checkPgBinLen`」ではない。 +- 実害の閾値は当初想定より低い。`points.len > int32.high`(21億点)で int32 wrap + だが、wire format の parameter length prefix は int32(2 GiB 上限)なので、 + buffer 全体 `1 + 4 + n*16` / `4 + n*16` が 2 GiB を超える ~134M 点で先に破綻。 + 加えて 32-bit プラットフォームでは `points.len * 16` の `int` wrap が `newSeq` + サイズ算出で発生し得る。 +- 修正: `buildFixedArray` と同じ payload-level スタイルに揃え、両 proc 冒頭に + `checkPgBinPayload(size, "path"/"polygon")` を追加。`size` は int64 演算で計算し + `newSeq[byte](size.int)` で確保。point-count の int32 キャストは payload 上限 + (16*n ≤ int32.high ⇒ n は int32 に安全)で自動的に包含されるため + `checkPgBinLen` は不要。 +- ステータス: branch `fix/pg_types-path-polygon-binary-payload-guard`(27c7cd4、未 PR)。 + 既存の PgPath/PgPolygon happy-path テストおよび roundtrip テスト全 pass。 + +## 補足: `toPgParamInline(PgUuid)` のデッドコード + +- `PgInlineBufSize = 16`(`core.nim:291`)、UUID 文字列は常に36バイト +- `encoding.nim:89` の `elif s.len <= PgInlineBufSize` 分岐は到達不能 +- コメント(line 81-82)で既に言及あり。バグではないが冗長 diff --git a/reviews/review_pg_sql.md b/reviews/review_pg_sql.md new file mode 100644 index 00000000..770b0901 --- /dev/null +++ b/reviews/review_pg_sql.md @@ -0,0 +1,52 @@ +# pg_sql.nim レビュー結論 + +## 指摘1: `?` 演算子の保存セット (pg_sql.nim:200) + +**実害なし。レビューの前提が事実誤認。** + +ltreeの演算子は `? lquery[]`(空白区切りの単独 `?`)、`~ lquery`、`@> ltree` であり、 +`?@` や `?~` という複合演算子は標準PostgreSQLに存在しない。 + +現状の保存セット `{'|', '&', '-', '#'}` の妥当性: + +- `?|`, `?&` — jsonb/hstoreの標準演算子。正しく保存 +- `?-`, `?#` — 標準ではないが拡張向けに防御的に保存(tests/test_sql.nim:141-157 で確認) +- 単独 `?`(jsonbキー存在確認 `data ? 'key'`、ltree `path ? ARRAY[...]::lquery[]`)は + `$N` に変換されるが、`??` エスケープで対応する設計上のトレードオフ(ドキュメント記載済み) + +`@` や `~` の追加は不要。標準演算子に `?@`/`?~` は存在せず、 +ユーザー定義演算子は `??` で対応可能。 + +## 指摘2: charリテラルスキップ (pg_sql.nim:248-257) + +**問題なし。** 全有効Nim charリテラルを正しく処理: + +- `'a'` → 3文字スキップ +- `'\''` → `\` + `'` を2文字スキップ +- `'\\'` → `\` + `\` を2文字スキップ +- `'\n'` → `\` + `n` を2文字スキップ + +無効な `'ab'` では閉じ `'` を消費しきれないが、後段の `parseExpr` が +コンパイルエラーを出すため実害ゼロ。tests/test_sql.nim:247(`'}'` を含む +charリテラル)で動作確認済み。 + +## 指摘3: `typedesc` 転送 (pg_sql.nim:379-380) + +**正しい。** `_: typedesc[T]` パラメータから `T` を抽出し、ターゲットprocの +`typedesc[T]` 引数に位置渡ししている。`queryValue[T]`, `queryValueOpt[T]`, +`queryValueOrDefault[T]` いずれもシグネチャと引数順序が一致。 + +## 指摘4: E-string検出 (pg_sql.nim:67-70) + +**正しい。** 直前文字が `E`/`e` かつその前が識別子文字でないことを確認: + +- `SELECT e'...'` → 空白の後の `e` → E-string +- `WHERE'...'` → `R` の後の `E` → 通常文字列(tests/test_sql.nim:58-60) +- `(e'...')` → `(` の後の `e` → E-string + +## 総合結論 + +4件の指摘すべてについて実害のあるバグは確認されなかった。 +指摘1の前提(ltreeの `?@`/`?~`)は事実誤認。 +コードはテストで十分にカバーされており、状態機械・マクロ・テンプレート生成 +いずれも正しく動作する。 diff --git a/tests/all_tests.nim b/tests/all_tests.nim index ec0f0754..29d444ed 100644 --- a/tests/all_tests.nim +++ b/tests/all_tests.nim @@ -1,13 +1,6 @@ +## Full test suite: the unit/mock tests plus the live-PostgreSQL integration +## tests. Requires a running PostgreSQL (see docker-compose.yml). For a run +## that needs no database, use all_tests_unit.nim instead. {.push warning[UnusedImport]: off.} -import - test_abandonment_e2e, test_advisory_lock, test_async_backend, test_auth, - test_cancel_e2e, test_copy_race, test_dsn, test_e2e_arrays, test_e2e_connection, - test_e2e_convenience, test_e2e_copy, test_e2e_cursor, test_e2e_listen, test_e2e_misc, - test_e2e_pool, test_e2e_query, test_e2e_transaction, test_e2e_types, - test_fill_recvbuf, test_keepalive, test_largeobject, test_listen_reconnect, - test_network_failure, test_physical_replication, test_pool, test_protocol, - test_protocol_fuzz, test_replication, test_replication_keepalive, test_rowdata, - test_saslprep, test_session_attrs, test_sql, test_ssl, test_tls_error_paths, - test_tracing, test_transaction_cancel, test_tx_cleanup_defect, test_types, - test_pool_cluster +import all_tests_unit, all_tests_integration {.pop.} diff --git a/tests/all_tests_integration.nim b/tests/all_tests_integration.nim new file mode 100644 index 00000000..97e950a6 --- /dev/null +++ b/tests/all_tests_integration.nim @@ -0,0 +1,10 @@ +## End-to-end tests that require a live PostgreSQL at 127.0.0.1:15432 +## (started via docker-compose.yml). Run these only where Docker/PostgreSQL +## is available; the unit/mock suite lives in all_tests_unit.nim. +{.push warning[UnusedImport]: off.} +import + test_abandonment_e2e, test_advisory_lock, test_cancel_e2e, test_e2e_arrays, + test_e2e_connection, test_e2e_convenience, test_e2e_copy, test_e2e_cursor, + test_e2e_listen, test_e2e_misc, test_e2e_pool, test_e2e_query, test_e2e_transaction, + test_e2e_types, test_largeobject, test_tracing +{.pop.} diff --git a/tests/all_tests_unit.nim b/tests/all_tests_unit.nim new file mode 100644 index 00000000..89f3464c --- /dev/null +++ b/tests/all_tests_unit.nim @@ -0,0 +1,12 @@ +## Unit and in-process mock-server tests. None of these require a live +## PostgreSQL: every test either exercises pure logic or connects to an +## in-process mock server on an ephemeral port. They therefore run anywhere, +## including CI hosts without Docker (e.g. macOS runners). +{.push warning[UnusedImport]: off.} +import + test_async_backend, test_auth, test_dsn, test_fill_recvbuf, test_keepalive, + test_listen_reconnect, test_network_failure, test_physical_replication, test_pool, + test_pool_cluster, test_protocol, test_protocol_fuzz, test_replication, + test_replication_keepalive, test_rowdata, test_saslprep, test_session_attrs, test_sql, + test_ssl, test_transaction_cancel, test_tx_cleanup_defect, test_types +{.pop.} diff --git a/tests/review_rowdata.md b/tests/review_rowdata.md new file mode 100644 index 00000000..a186f930 --- /dev/null +++ b/tests/review_rowdata.md @@ -0,0 +1,41 @@ +# test_rowdata.nim レビュー結論 + +## コードレビュー指摘 + +### 1. テスト名と検証内容の矛盾 (低〜中) + +- **Line 180** `"old RowData remains intact after reuse"`: 実際にはmoveで空になることを検証している。 +- **Line 199** `"old RowData with data preserved when previous result held"`: 実際には `rd1 != rd2` (異なるref) のみを検証。 +- 提案: `"reuse moves buffers out of the old RowData"` 等にリネーム。 + +### 2. overflow guardテストが約2 GiB確保 (低) + +- Lines 291–312: `setLen(int32.high)` 付近のseqを3つ確保。CIのメモリ上限が低いと失敗しうる。 + +### 3. NULLセンチネル `\xFF` の制限 (情報) + +- Line 10: 将来 `0xFF` を含むバイナリデータを非NULLとしてテストしたい場合、このヘルパーでは表現不可。現時点で実害なし。 + +## カバレッジ分析 + +### カバー済み + +- `clone` — 全分岐 (nil, NULL, 空文字列, 通常データ, マルチ行, colFormats/colTypeOids/fields複製) +- `reuseRowData` — 全分岐 (capacity保持, colFormats更新, 複数サイクル, 旧参照整合性) +- `parseDataRowInto` — numCols mismatch, int32.high overflow, ロールバック, NULL/空文字列 + +### 未カバー + +| パス | 備考 | +|------|------| +| `parseDataRowInto`: "message too short" / "invalid column count (負値)" / "unexpected end" / "invalid column length" / "truncated" | fuzzテストで間接カバーのみ。決定的テストなし | +| 上記エラー時のロールバック (cellIndex/bufのsetLen) | numCols mismatchとoverflow以外は未検証 | +| `buildResultFormats` | 全テストファイルで未テスト | +| `dataLen == 0` (0列DataRow) | 境界ケース未テスト | +| accessors群 (`cellInfo`, `[]`, `isNull`, `toRow`等) | test_types.nimで間接カバー | + +### 追加推奨テスト + +1. `parseDataRowInto` の各エラー分岐に対する決定的なユニットテスト + ロールバック検証 +2. `buildResultFormats` の直接テスト +3. 0列DataRow (`body = [0x00, 0x00]`) の境界テスト diff --git a/tests/test_keepalive.nim b/tests/test_keepalive.nim index c91b57dd..57bceb01 100644 --- a/tests/test_keepalive.nim +++ b/tests/test_keepalive.nim @@ -15,6 +15,11 @@ suite "configureKeepalive": doAssert fd != SocketHandle(-1), "socket() failed" fd + proc keepaliveEnabled(fd: SocketHandle): bool = + # macOS/BSD getsockopt returns the SO_KEEPALIVE flag bit (8), Linux returns 1, + # so treat any non-zero value as "enabled". + getIntSockOpt(fd, SOL_SOCKET, SO_KEEPALIVE) != 0 + test "keepAlive=false does not set SO_KEEPALIVE": let fd = makeSocket() defer: @@ -22,7 +27,7 @@ suite "configureKeepalive": var config = ConnConfig() config.keepAlive = false configureKeepalive(fd, config) - check getIntSockOpt(fd, SOL_SOCKET, SO_KEEPALIVE) == 0 + check not keepaliveEnabled(fd) test "keepAlive=true sets SO_KEEPALIVE": let fd = makeSocket() @@ -31,7 +36,7 @@ suite "configureKeepalive": var config = ConnConfig() config.keepAlive = true configureKeepalive(fd, config) - check getIntSockOpt(fd, SOL_SOCKET, SO_KEEPALIVE) == 1 + check keepaliveEnabled(fd) test "keepAlive with idle/interval/count": let fd = makeSocket() @@ -43,7 +48,7 @@ suite "configureKeepalive": config.keepAliveInterval = 7 config.keepAliveCount = 3 configureKeepalive(fd, config) - check getIntSockOpt(fd, SOL_SOCKET, SO_KEEPALIVE) == 1 + check keepaliveEnabled(fd) when defined(linux): check getIntSockOpt(fd, cint(posix.IPPROTO_TCP), TCP_KEEPIDLE) == 42 check getIntSockOpt(fd, cint(posix.IPPROTO_TCP), TCP_KEEPINTVL) == 7 @@ -63,7 +68,7 @@ suite "configureKeepalive": config.keepAliveInterval = 0 config.keepAliveCount = 0 configureKeepalive(fd, config) - check getIntSockOpt(fd, SOL_SOCKET, SO_KEEPALIVE) == 1 + check keepaliveEnabled(fd) test "keepAlive=false with timing params does not set SO_KEEPALIVE": let fd = makeSocket() @@ -75,7 +80,7 @@ suite "configureKeepalive": config.keepAliveInterval = 10 config.keepAliveCount = 3 configureKeepalive(fd, config) - check getIntSockOpt(fd, SOL_SOCKET, SO_KEEPALIVE) == 0 + check not keepaliveEnabled(fd) test "partial timing (idle only)": let fd = makeSocket() @@ -85,7 +90,7 @@ suite "configureKeepalive": config.keepAlive = true config.keepAliveIdle = 99 configureKeepalive(fd, config) - check getIntSockOpt(fd, SOL_SOCKET, SO_KEEPALIVE) == 1 + check keepaliveEnabled(fd) when defined(linux): check getIntSockOpt(fd, cint(posix.IPPROTO_TCP), TCP_KEEPIDLE) == 99 elif defined(macosx): diff --git a/tests/test_ssl_coverage.md b/tests/test_ssl_coverage.md new file mode 100644 index 00000000..cc381f1d --- /dev/null +++ b/tests/test_ssl_coverage.md @@ -0,0 +1,47 @@ +# test_ssl.nim カバレッジ分析 + +対象: `async_postgres/pg_connection/ssl.nim` (612行) + +## カバー済み + +| 対象 | テスト数 | +|---|---| +| `sniName` | 6 (DNS/IPv4/IPv6/empty/sslSni=false/numeric-looking) | +| `negotiateSSL` SSLRequest パス ('S'/'N'/unexpected/closed) | 5 | +| CVE-2021-23214 対策 (`extraBytesBuffered` + `socketHasPendingData`) | 2 (single-write / split-write) | +| sslmode 各動作 (disable/allow/prefer/require/verify-ca/verify-full) | 10+ | +| `validateDirectSslCompatible` | 2 | +| `validateClientCertConfig` (sslCert/sslKey ペアリング) | 8 | +| チャネルバインディング (`selectScramMechanism`: cbRequire/cbDisable/cbPrefer) | 12 | +| `enforceVerifyFullIdentity` (IP-SAN / DNS-SAN) | 4 (asyncdispatch のみ) | +| `parseTrustAnchors` / `appendDnCallback` (空ブロック/ゴミ/サイズ溢出) | 6 | +| `installX509Capture` / `rebindX509Capture` | 2 | +| ALPN 広告 (ClientHello 内) | 1 | + +## 未カバー / 実 TLS が必要でテスト困難 + +| 対象 | 理由 | +|---|---| +| `establishTls` の実際のハンドシェイク成功パス | ✅ 対処済み — e2e TLS テスト(test_e2e_connection.nim)に加え、test_tls_error_paths.nim の ALPN テストが実ハンドシェイクを実行 | +| `driveTlsHandshake` の peer close 分岐 | ✅ 対処済み(2026-08-13)— test_tls_error_paths.nim: モック 'S' 応答後 close で `recv == 0` 分岐を実証(asyncdispatch) | +| `assertAlpnPostgres` のエラーパス(ALPN 無し) | ✅ 対処済み(2026-08-13)— openssl s_server(ALPN 非広告)で「without ALPN」分岐を両バックエンド実証。※ `-alpn <別名>` の場合は peer が handshake を abort(alert no application protocol)するため「不正プロトコル」分岐には到達不能 | +| `failPemPassphrase` / 暗号化鍵の拒否 | ✅ 対処済み(2026-08-13)— tests/certs/encrypted.key(PKCS#1 伝統型暗号化、Proc-Type: 4,ENCRYPTED)フィクスチャで両バックエンド実証 | +| `writeTempPem` / 一時ファイル清理 (asyncdispatch) | ✅ 対処済み(2026-08-13)— 上記 4 テストが establishTls 経由で writeTempPem + finally 清理経路を実行 | +| `SSL_CTX_load_verify_locations` / `use_certificate_chain_file` / `use_PrivateKey_file` の失敗分岐 | ✅ 対処済み(2026-08-13)— ゴミ CA/cert/key コンテンツで各ロード失敗分岐を実証 | +| `SSL_CTX_check_private_key`(cert/key 不一致) | ✅ 対処済み(2026-08-13、asyncdispatch)— server.crt + wrong_ca.key で実証。※ OpenSSL 3.6 は use_PrivateKey_file 段階で "key values mismatch" を検出(check_private_key に到達しない)。chronos はクライアント側検証が無い(サーバがクライアント認証を要求しない限り黙って成功)— 非対称として記録済み | +| `sslCtxSetAlpnProtos` が nil または rc!=0 のパス | 未対処 — dynlib 解決結果に依存(Apple 系のみ nil になり得る) | +| asyncdispatch TLS 下限 (TLS 1.2+ 強制) の downgrade 拒否 | 未対処 — modern OpenSSL に TLS 1.0/1.1 のみ受ける peer が構築不能(実装できない) | +| chronos verify-full + IP-literal host 事前診断 | ✅ 対処済み(既存、mock テスト) | +| chronos require での期限切れ証明書拒否 | 未対処 — 期限切れ証明書での実ハンドシェイク peer が必要(openssl s_server -cert <期限切れ> で構築可能だが、verify モードでは CA ピン留めが主経路のため優先度低) | +| chronos 側: `TLSCertificate.init` 失敗, `TLSStreamInitError` | ✅ 対処済み(2026-08-13)— ゴミ cert/key コンテンツで両バックエンド実証(test_tls_error_paths.nim) | +| `formatSslError` / `resolveSym` | 未対処 — エラー時 / モジュール初期化時のみ(formatSslError はキー読込失敗経路で間接実行) | + +## 総評 + +ロジック分岐のカバレッジは高い (negotiateSSL の全応答分岐、sslmode 行列、 +CVE-2021-23214 の両検出パス、チャネルバインディングの全 cbMode)。 +2026-08-13 の test_tls_error_paths.nim 新設で、従来「実 TLS が必要でテスト困難」だった +cert/key/CA 読込失敗分岐・暗号化鍵拒否・peer close・ALPN 欠如が実証可能になった +(cert/key/CA ロード失敗は SSLRequest 'S' 応答のみで establishTls のロード段階まで到達できる)。 +残る未カバーは TLS 1.0 ダウングレード拒否(peer が現存しない)と dynlib nil 分岐(Apple 系のみ)の +非現実的経路のみ。