max / makenotwork
13 files changed,
+370 insertions,
-124 deletions
| @@ -4,6 +4,15 @@ Full chronological audit log. See [audit_review.md](./audit_review.md) for curre | |||
| 4 | 4 | ||
| 5 | 5 | ## Changes Since Last Audit | |
| 6 | 6 | ||
| 7 | + | ### Tenth formal audit (2026-04-30, Run 17 cross-project) | |
| 8 | + | - **Test count:** 228 (58 unit + 170 integration). 0 clippy warnings. 0 failures. | |
| 9 | + | - **Grade:** A (maintained). v0.3.4. ~9,928 LOC. | |
| 10 | + | - **Scorecard upgrades:** Performance A- -> A (batch queries for all N+1 paths confirmed). Documentation B+ -> A- (module-level //! docs on all significant files). Observability B+ -> A (#[instrument] on every handler and DB function). Codebase Size B+ -> A (9,928 LOC lean for full forum). Migration Safety A+ -> A- (early migrations 001-007 lack IF NOT EXISTS). | |
| 11 | + | - **Cold spots (2):** seed.rs (1,032 LOC, B+) and early migrations 001-007 (lack IF NOT EXISTS, B+). | |
| 12 | + | - **Mandatory surprise:** Link preview fetching runs synchronously in the handler. Posts with multiple slow URLs could block for up to 15s. Also: constant_time_compare returns false immediately on length mismatch (non-issue: tokens are always fixed 64-char hex). | |
| 13 | + | - **All 13 previously resolved items verified intact.** No regressions. | |
| 14 | + | - **New action item:** [LOW] Consider spawning link preview fetch as detached task (routes/forum/posts.rs). | |
| 15 | + | ||
| 7 | 16 | ### Seventh formal audit (2026-03-28, Run 12 cross-project) | |
| 8 | 17 | - **Test count:** 225 (35 unit lib + 190 integration). 0 clippy warnings. 0 failures. | |
| 9 | 18 | - **Grade:** A (maintained). v0.3.2. |
| @@ -1,11 +1,11 @@ | |||
| 1 | 1 | # Multithreaded -- Code Audit Review | |
| 2 | 2 | ||
| 3 | - | **Last audited:** 2026-04-18 (ninth audit, Run 15 cross-project) | |
| 4 | - | **Previous audit:** 2026-04-15 (eighth audit, Run 14 cross-project) | |
| 3 | + | **Last audited:** 2026-04-30 (tenth audit, Run 17 cross-project) | |
| 4 | + | **Previous audit:** 2026-04-18 (ninth audit, Run 15 cross-project) | |
| 5 | 5 | ||
| 6 | 6 | ## Overall Grade: A | |
| 7 | 7 | ||
| 8 | - | Run 15 cross-project audit (updated 2026-04-22). 232 tests (all pass). 0 clippy warnings. v0.3.4. ~9,752 LOC. Health endpoint now verifies DB connectivity. OG image URL finding was incorrect (code never fetches/stores og:image -- only og:title and og:description). MNW API retry logic was already implemented (token exchange + userinfo both retry with backoff). | |
| 8 | + | Run 17 cross-project audit (remediated 2026-05-01). 228 tests (58 unit + 170 integration, all pass). 0 clippy warnings. v0.3.4. ~9,928 LOC. All 13 previously resolved items verified intact, no regressions. Link preview fetch now spawned as detached background task (fixed 2026-05-01). | |
| 9 | 9 | ||
| 10 | 10 | ## Scorecard | |
| 11 | 11 | ||
| @@ -13,19 +13,19 @@ Run 15 cross-project audit (updated 2026-04-22). 232 tests (all pass). 0 clippy | |||
| 13 | 13 | |-----------|:-----:|-------| | |
| 14 | 14 | | Code Quality | A | Zero clippy warnings. Consistent `map_err` + `tracing::error!` error handling. Mod log failures logged. | | |
| 15 | 15 | | Architecture | A+ | Clean 3-crate workspace: mt-core (time formatting), mt-db (queries/mutations), main app (routes, auth, templates). Route module properly split. Template layer uses view-model structs. | | |
| 16 | - | | Testing | A+ | 231 tests (all pass) at ~24 tests/KLOC. Integration tests use real PostgreSQL with per-test database isolation. Coverage on CRUD, permissions, bans, mute/unban/unmute, CSRF, pagination, rate limiting, category edit/reorder, endorsements, footnotes, verified quoting, mentions, link previews, profiles, auth flow (PKCE/state), admin routes. | | |
| 16 | + | | Testing | A | 228 tests (58 unit + 170 integration, all pass) at ~23 tests/KLOC. Integration tests use real PostgreSQL with per-test database isolation. Coverage on CRUD, permissions, bans, mute/unban/unmute, CSRF, pagination, rate limiting, category edit/reorder, endorsements, footnotes, verified quoting, mentions, link previews, profiles, auth flow (PKCE/state), admin routes. | | |
| 17 | 17 | | Security | A | All SQL parameterized. CSRF with constant-time comparison. OAuth PKCE with state nonce. Markdown via docengine (URL scheme allowlist + HTML sanitization). Fail-closed access checks. Link previews only store title/description, not og:image. | | |
| 18 | - | | Performance | A- | Proper indexes on all query patterns. Partial index on ban expiration. No N+1 queries. Per-IP rate limiting. | | |
| 19 | - | | Documentation | B+ | `//!` module docs on all source files. `.env.example` documents all env vars. Some gaps in architecture docs. | | |
| 18 | + | | Performance | A | Proper indexes on all query patterns. Partial index on ban expiration. Batch queries for all N+1 paths confirmed. Per-IP rate limiting. | | |
| 19 | + | | Documentation | A- | Module-level `//!` docs on all significant files. `.env.example` documents all env vars. Some view-model structs lack doc comments. | | |
| 20 | 20 | | Dependencies | A | Minimal deps, all justified. Rust 2024 edition. Dead deps removed. Workspace dependency management. | | |
| 21 | 21 | | Frontend | A | HTMX for dynamic interactions. Askama autoescaping. CSRF auto-injected. Toast uses `textContent`. `body_html` sanitized by docengine. | | |
| 22 | 22 | | Type Safety | A+ | Query layer uses focused `FromRow` projections. Clean domain types. | | |
| 23 | - | | Observability | B+ | 86 `#[instrument(skip_all)]` annotations. `tracing-subscriber` with EnvFilter. Gaps in some newer code paths. | | |
| 23 | + | | Observability | A | `#[instrument]` on every handler and every DB function. `tracing-subscriber` with EnvFilter. | | |
| 24 | 24 | | Concurrency | A | Async throughout with tokio. Graceful shutdown. reqwest timeouts. `swap_category_order` uses transaction. Per-IP rate limiting. | | |
| 25 | 25 | | Resilience | A | Graceful shutdown. HTTP client timeouts. Error logging without panics. Rate limiting. MNW API calls retry with backoff on network/5xx errors. | | |
| 26 | 26 | | API Consistency | A | Consistent redirect-with-toast pattern. Proper status codes (403/404/422). Health endpoint returns JSON. | | |
| 27 | - | | Migration Safety | A+ | SQLx `migrate!()` with sequential numbering. All additive. No destructive operations. | | |
| 28 | - | | Codebase Size | B+ | ~9,752 LOC -- lean for full forum with OAuth, CSRF, markdown, moderation, admin, pagination, soft-delete, and settings. | | |
| 27 | + | | Migration Safety | A- | SQLx `migrate!()` with sequential numbering. Later migrations use IF NOT EXISTS. Early migrations (001-007) lack it. Protected by sqlx tracking. | | |
| 28 | + | | Codebase Size | A | ~9,928 LOC -- lean for full forum with OAuth, CSRF, markdown, moderation, admin, pagination, soft-delete, and settings. | | |
| 29 | 29 | ||
| 30 | 30 | ## Module Heatmap | |
| 31 | 31 | ||
| @@ -49,15 +49,14 @@ Run 15 cross-project audit (updated 2026-04-22). 232 tests (all pass). 0 clippy | |||
| 49 | 49 | ||
| 50 | 50 | ### Cold Spots | |
| 51 | 51 | ||
| 52 | - | 1. ~~**Health endpoint**~~ -- Fixed 2026-04-22. Now checks DB with `SELECT 1` and returns `"degraded"` status if unreachable. | |
| 53 | - | 2. ~~**Link preview OG image URL**~~ -- Incorrect finding. Code only extracts og:title and og:description. No og:image URLs are fetched or stored. | |
| 54 | - | 3. ~~**MNW API retry logic**~~ -- Already implemented. Token exchange and userinfo both retry with 500ms/1000ms backoff on network/5xx errors. | |
| 52 | + | 1. **seed.rs (1,032 LOC) -- B+**: Large for seed data. Content is substantive but could externalize to TOML/JSON. | |
| 53 | + | 2. **Early migrations (001-007) -- B+**: Lack IF NOT EXISTS guards. Mitigated by sqlx migration tracking. | |
| 55 | 54 | ||
| 56 | 55 | ## Strengths | |
| 57 | 56 | ||
| 58 | 57 | - **Clean architecture.** 3-crate workspace with proper separation. Route module split into focused files. Template layer uses view-model structs. | |
| 59 | 58 | - **Comprehensive CSRF.** Synchronizer token with constant-time comparison, auto-injected via JS for all forms and HTMX requests. | |
| 60 | - | - **Solid test infrastructure.** Full Axum app with real PostgreSQL per test. Cookie-aware client with automatic CSRF token extraction. 231 tests all passing. | |
| 59 | + | - **Solid test infrastructure.** Full Axum app with real PostgreSQL per test. Cookie-aware client with automatic CSRF token extraction. 228 tests all passing. | |
| 61 | 60 | - **Authorization hierarchy.** Owner > mod > member correctly enforced. Owners cannot be banned. Only owners can ban mods. | |
| 62 | 61 | - **Input validation.** Length limits on all user content. Slug format validation. UUID parsing validated. Sort/order whitelisted. | |
| 63 | 62 | - **SQL safety.** All 40+ queries parameterized. Dynamic ORDER BY uses whitelist match. | |
| @@ -70,15 +69,16 @@ Run 15 cross-project audit (updated 2026-04-22). 232 tests (all pass). 0 clippy | |||
| 70 | 69 | ||
| 71 | 70 | ## Mandatory Surprise | |
| 72 | 71 | ||
| 73 | - | **~~Link preview image URL vulnerability~~ (Incorrect finding)** | |
| 72 | + | **Link preview fetching blocks post creation** | |
| 74 | 73 | ||
| 75 | - | The original Run 15 audit claimed og:image URLs were fetched and rendered without validation. This was incorrect -- `fetch_og_metadata` only extracts `og:title` and `og:description`. The `LinkPreviewViewRow` struct has `url` (the original post link, already validated by `validate_url`), `title`, and `description` -- no image field. Verified 2026-04-22 by reading link_preview.rs and templates/public.rs. | |
| 74 | + | ~~Link preview fetching runs synchronously in the handler.~~ Fixed 2026-05-01. `spawn_link_preview_fetch` now runs as a detached `tokio::spawn` task. Post creation returns immediately; previews appear asynchronously. | |
| 76 | 75 | ||
| 77 | - | **Verdict:** No issue. | |
| 76 | + | Also notable: The `constant_time_compare` in csrf.rs returns false immediately on length mismatch (leaks token length via timing). In practice this is a non-issue because CSRF tokens are always 64-char hex (fixed length). | |
| 78 | 77 | ||
| 79 | - | ### Previous Surprise (Run 12) | |
| 78 | + | ### Previous Surprises | |
| 80 | 79 | ||
| 81 | - | **`CoreError` dead code** -- fully-defined typed error enum with 5 variants was never used anywhere. **Resolved:** Dead code removed. | |
| 80 | + | - **Run 15:** ~~Link preview image URL vulnerability~~ -- Incorrect finding. Code never fetches og:image. | |
| 81 | + | - **Run 12:** ~~`CoreError` dead code~~ -- Resolved. Dead code removed. | |
| 82 | 82 | ||
| 83 | 83 | ## Action Items | |
| 84 | 84 | ||
| @@ -101,6 +101,9 @@ Filed in `docs/mnw/mt/todo.md`. | |||
| 101 | 101 | 12. ~~**[MEDIUM]** Add DB connectivity check to health endpoint~~ -- Fixed 2026-04-22. | |
| 102 | 102 | 13. ~~**[LOW]** Add retry logic to MNW OAuth API calls~~ -- Already implemented (auth.rs:210-316). | |
| 103 | 103 | ||
| 104 | + | ### Run 17 (2026-04-30) | |
| 105 | + | 14. ~~**[LOW]** Spawn link preview fetch as detached task~~ -- Done 2026-05-01. | |
| 106 | + | ||
| 104 | 107 | ## Metrics Over Time | |
| 105 | 108 | ||
| 106 | 109 | | Audit Date | LOC | Rust Files | Tests | Tests/KLOC | Clippy Warnings | Cold Spots | Overall | | |
| @@ -119,6 +122,7 @@ Filed in `docs/mnw/mt/todo.md`. | |||
| 119 | 122 | | 2026-04-15 (Run 14) | ~9,752 | -- | 231 | ~24 | 0 | 0 | A | | |
| 120 | 123 | | 2026-04-18 (Run 15) | ~9,752 | -- | 231 | ~24 | 0 | 3 | A- | | |
| 121 | 124 | | 2026-04-22 (Run 15 corrected) | ~9,752 | -- | 232 | ~24 | 0 | 0 | A | | |
| 125 | + | | 2026-04-30 (Run 17) | ~9,928 | -- | 228 | ~23 | 0 | 2 | A | | |
| 122 | 126 | ||
| 123 | 127 | --- | |
| 124 | 128 |
| @@ -2,33 +2,12 @@ | |||
| 2 | 2 | ||
| 3 | 3 | Done: All pre-beta phases. Active: None. Next: Platform integration. | |
| 4 | 4 | ||
| 5 | - | v0.3.4. Audit grade A. 232 tests. | |
| 6 | - | ||
| 7 | - | --- | |
| 8 | - | ||
| 9 | - | ## Code Fuzz Findings (2026-04-26) | |
| 10 | - | ||
| 11 | - | ### Critical | |
| 12 | - | - [x] Cross-community post removal via flag: scoped flag queries to community_id via JOIN chain. (fixed 2026-04-26) | |
| 13 | - | ||
| 14 | - | ### Serious | |
| 15 | - | - [x] Cross-community category edit: added `AND community_id = $2` to UPDATE. (fixed 2026-04-26) | |
| 16 | - | - [x] Cross-community tag delete: added `AND community_id = $2` to DELETE. (fixed 2026-04-26) | |
| 17 | - | - [x] Search query UTF-8 panic: use `is_char_boundary` loop for safe truncation. (fixed 2026-04-26) | |
| 18 | - | - [x] SSRF bypass in link preview: replaced string matching with `std::net::IpAddr` parsing + DNS resolution to catch all IP encodings. (fixed 2026-04-26) | |
| 19 | - | ||
| 20 | - | ### Minor | |
| 21 | - | - [x] `parse_duration` silent default to permanent: now returns error on unrecognized values. (fixed 2026-04-26) | |
| 22 | - | - [x] `/internal/threads/{id}/stats` unauthenticated: added HMAC verification + signed GET in MNW client. (fixed 2026-04-26) | |
| 23 | - | - [x] `auto_hide_if_threshold_met` audit trail: set `removed_by` to NULL for system-initiated removals; mod log records the event. (fixed 2026-04-26) | |
| 24 | - | - [x] `/search` rate limiting: added per-IP governor (burst 5, 1/sec) on search endpoint. (fixed 2026-04-26) | |
| 5 | + | v0.3.4. Audit grade A. 228 tests. | |
| 25 | 6 | ||
| 26 | 7 | --- | |
| 27 | 8 | ||
| 28 | 9 | ## Code Review Remediation — Remaining | |
| 29 | 10 | - [ ] Transitive dep advisories: rand 0.8/0.9 (RUSTSEC-2026-0097), rsa (RUSTSEC-2023-0071), lru (RUSTSEC-2026-0002) — no direct fix available, monitor upstream | |
| 30 | - | - [ ] Add partial index on `posts.removed_at` (`WHERE removed_at IS NOT NULL`) when data volume warrants it | |
| 31 | - | - [x] Add `tracing::warn!` to `MaybeUser` extractor on session read errors (fixed 2026-04-26) | |
| 32 | 11 | ||
| 33 | 12 | --- | |
| 34 | 13 | ||
| @@ -84,7 +63,7 @@ v0.3.4. Audit grade A. 232 tests. | |||
| 84 | 63 | | Seed data | `src/seed.rs` | | |
| 85 | 64 | | Entry point | `src/main.rs` | | |
| 86 | 65 | | Library root | `src/lib.rs` | | |
| 87 | - | | Migrations | `migrations/` (001-021) | | |
| 66 | + | | Migrations | `migrations/` (001-024) | | |
| 88 | 67 | | CSS | `static/style.css` | | |
| 89 | 68 | | Deploy config | `deploy/` | | |
| 90 | 69 | | Integration tests | `tests/` | |
| @@ -156,10 +156,18 @@ pub(super) async fn resolve_and_render_mentions( | |||
| 156 | 156 | // Link preview pipeline | |
| 157 | 157 | // ============================================================================ | |
| 158 | 158 | ||
| 159 | + | /// Spawn link preview fetching as a detached background task. | |
| 160 | + | /// The HTTP response returns immediately; previews appear asynchronously. | |
| 161 | + | fn spawn_link_preview_fetch(state: AppState, body: String, post_id: Uuid) { | |
| 162 | + | tokio::spawn(async move { | |
| 163 | + | fetch_and_store_link_previews(&state, &body, post_id).await; | |
| 164 | + | }); | |
| 165 | + | } | |
| 166 | + | ||
| 159 | 167 | /// Extract URLs from post body, fetch OG metadata, and store previews. | |
| 160 | 168 | /// Best-effort: fetch failures are logged but never block post creation. | |
| 161 | 169 | #[tracing::instrument(skip_all)] | |
| 162 | - | pub(super) async fn fetch_and_store_link_previews(state: &AppState, body: &str, post_id: Uuid) { | |
| 170 | + | async fn fetch_and_store_link_previews(state: &AppState, body: &str, post_id: Uuid) { | |
| 163 | 171 | let urls = crate::link_preview::extract_urls(body); | |
| 164 | 172 | for url in urls { | |
| 165 | 173 | match crate::link_preview::fetch_og_metadata(&state.preview_http, &url).await { | |
| @@ -248,8 +256,8 @@ pub(in crate::routes) async fn create_thread_handler( | |||
| 248 | 256 | })?; | |
| 249 | 257 | } | |
| 250 | 258 | ||
| 251 | - | // Fetch link previews (best-effort, failures don't block post creation) | |
| 252 | - | fetch_and_store_link_previews(&state, body, post_id).await; | |
| 259 | + | // Fetch link previews in background (best-effort, never blocks response) | |
| 260 | + | spawn_link_preview_fetch(state.clone(), body.to_string(), post_id); | |
| 253 | 261 | ||
| 254 | 262 | // Save tags if any were selected | |
| 255 | 263 | if !form.tags.is_empty() { | |
| @@ -324,8 +332,8 @@ pub(in crate::routes) async fn create_reply_handler( | |||
| 324 | 332 | })?; | |
| 325 | 333 | } | |
| 326 | 334 | ||
| 327 | - | // Fetch link previews (best-effort) | |
| 328 | - | fetch_and_store_link_previews(&state, body, post_id).await; | |
| 335 | + | // Fetch link previews in background (best-effort, never blocks response) | |
| 336 | + | spawn_link_preview_fetch(state.clone(), body.to_string(), post_id); | |
| 329 | 337 | ||
| 330 | 338 | Ok(Redirect::to(&format!( | |
| 331 | 339 | "/p/{slug}/{category_slug}/{thread_id_str}?toast=Reply+posted" |
| @@ -4,6 +4,17 @@ Full chronological audit log. See [audit_review.md](./audit_review.md) for curre | |||
| 4 | 4 | ||
| 5 | 5 | ## Changes Since Last Audit | |
| 6 | 6 | ||
| 7 | + | ### Thirteenth audit (2026-04-30, Run 17 cross-project) | |
| 8 | + | - **Test count:** 365 (236 unit + 129 integration). 0 clippy warnings. 0 failures. | |
| 9 | + | - **Grade:** A- (downgraded from A). v0.3.5. ~15,061 LOC. | |
| 10 | + | - **New cold spots:** | |
| 11 | + | - checks/cors.rs (B+) -- only 2 serde tests, zero coverage of actual validation logic | |
| 12 | + | - checks/ssh_banner.rs (B+) -- only 1 test, degraded-status branches untested | |
| 13 | + | - cli/tasks/whois.rs (B+) -- uses TLS interval config instead of dedicated WHOIS interval (copy-paste bug) | |
| 14 | + | - **Mandatory surprise:** RateLimiter atomic ordering race -- `try_acquire()` uses Relaxed ordering on counter, allowing max_per_window + N requests in a burst. Technically incorrect, harmless at current scale. | |
| 15 | + | - **Previous action items:** Item 2 (aws-lc-sys CVEs) still upstream-blocked. All resolved items verified intact. | |
| 16 | + | - **New action items:** 2 MEDIUM (CORS tests, SSH banner tests), 2 LOW (WHOIS config field, RateLimiter ordering fix). | |
| 17 | + | ||
| 7 | 18 | ### Tenth audit (2026-03-28, Run 12 cross-project) | |
| 8 | 19 | - **Test count:** 359 (222 unit + 8 cli + 129 integration). 0 clippy warnings. 0 failures. | |
| 9 | 20 | - **Grade:** A (maintained). v0.3.2. | |
| @@ -113,3 +124,4 @@ Full chronological audit log. See [audit_review.md](./audit_review.md) for curre | |||
| 113 | 124 | | 2026-03-14 | ~3.5K | ~16 | 238 | ~68 | 0 | 0 | A | | |
| 114 | 125 | | 2026-03-16 | 10.1K | 23 | 344 | ~34 | 2 | 0 | A | | |
| 115 | 126 | | 2026-03-18 | 10.1K | 23 | 344 | ~34 | 0 | 0 | A | | |
| 127 | + | | 2026-04-30 | ~15,061 | -- | 365 | ~15.6 | 0 | 3 | A- | |
| @@ -1,11 +1,11 @@ | |||
| 1 | 1 | # PoM (Peace of Mind) -- Audit Review | |
| 2 | 2 | ||
| 3 | - | **Last audited:** 2026-04-18 (twelfth audit, Run 15 cross-project) | |
| 4 | - | **Previous audit:** 2026-04-15 (eleventh audit, Run 14 cross-project) | |
| 3 | + | **Last audited:** 2026-04-30 (thirteenth audit, Run 17 cross-project) | |
| 4 | + | **Previous audit:** 2026-04-18 (twelfth audit, Run 15 cross-project) | |
| 5 | 5 | ||
| 6 | 6 | ## Overall Grade: A | |
| 7 | 7 | ||
| 8 | - | Run 15 cross-project audit (updated 2026-04-22). 364 tests (all pass). 0 clippy warnings. v0.3.5. ~11,496 LOC. Migration idempotency finding was incorrect -- all 9 migrations have IF NOT EXISTS guards. Only remaining issue: 2 critical CVEs in aws-lc-sys transitive dependency (blocked on upstream, no direct exposure). | |
| 8 | + | Run 17 cross-project audit (remediated 2026-05-01). 376 tests (247 unit + 129 integration, all pass). 0 clippy warnings. v0.3.5. ~15,061 LOC. All 4 Run 17 findings fixed: CORS validation extracted and tested (10 tests), SSH banner branches tested (3 tests), WHOIS interval config field added, RateLimiter atomic ordering corrected. | |
| 9 | 9 | ||
| 10 | 10 | ## Scorecard | |
| 11 | 11 | ||
| @@ -13,18 +13,16 @@ Run 15 cross-project audit (updated 2026-04-22). 364 tests (all pass). 0 clippy | |||
| 13 | 13 | |-----------|:-----:|-------| | |
| 14 | 14 | | Code Quality | A | Zero clippy warnings. Clean error handling with typed PomError enum. All migrations have IF NOT EXISTS guards. | | |
| 15 | 15 | | Architecture | A | Well-structured single crate. main.rs is thin, CLI handlers extracted to cli.rs. Module boundaries clean. | | |
| 16 | - | | Testing | A | 364 tests (all pass) for ~11.5K LOC = ~32 tests/KLOC. Comprehensive coverage -- DB, API, MCP tools, parsing, peer state machine, check_health, check_tls, rate limiter, CORS monitoring. | | |
| 17 | - | | Security | A | All SQL parameterized. SSH hardened. Bearer token auth. Rate limiting. HTTP response size limits. aws-lc-sys CVEs are transitive with no direct exposure (blocked on upstream). | | |
| 18 | - | | Performance | A- | WAL mode, proper indexes, async throughout. Appropriate for monitoring workload. | | |
| 19 | - | | Documentation | A | Module-level `//!` docs on all modules. All struct fields documented. architecture.md, README created. | | |
| 20 | - | | Dependencies | A- | 17 direct deps, all justified. 2 critical CVEs in aws-lc-sys transitive dependency (no direct exposure, blocked on upstream). | | |
| 16 | + | | Testing | A | 376 tests (247 unit + 129 integration), ~16 tests/KLOC. CORS and SSH banner gaps fixed. | | |
| 17 | + | | Security | A | Constant-time auth, SSH command injection prevention, response size caps. | | |
| 18 | + | | Performance | A- | WAL mode, proper indexes. Sequential per-target checks intentional. | | |
| 19 | + | | Documentation | A- | Module-level docs on all modules. Field-level docs on struct fields. | | |
| 20 | + | | Dependencies | A- | All deps latest stable. rmcp is 0.1.x (pre-1.0 but current). | | |
| 21 | 21 | | Type Safety | A | Domain enums with proper serde. No stringly-typed fields. | | |
| 22 | - | | Observability | A- | 57 `#[instrument(skip_all)]` annotations. Debug-level logging for non-critical failures. Watchdog logging. | | |
| 23 | - | | Concurrency | A | Clean RwLock discipline. Lock-then-release-then-DB pattern. CancellationToken for cooperative shutdown. | | |
| 24 | - | | Resilience | A | CancellationToken + `tokio::select!` in all task loops. Graceful shutdown. Watchdog for task panic detection. | | |
| 25 | - | | API Consistency | A | Consistent JSON shapes. Proper HTTP status codes. Public `/api/health` endpoint. | | |
| 26 | - | | Migration Safety | A | All 9 migrations use IF NOT EXISTS guards. Idempotent and safe to re-run. | | |
| 27 | - | | Codebase Size | A- | ~11,496 LOC for full monitoring tool with CLI, MCP server, HTTP API, SQLite, SSH test runner, peer mesh, and self-monitoring. | | |
| 22 | + | | Observability | A | tracing with #[instrument] on all DB/check functions. | | |
| 23 | + | | Concurrency | A | Clean lock ordering in peer mesh. Rate limiter atomic ordering fixed (Acquire/Release). | | |
| 24 | + | | Resilience | A | Graceful shutdown, timeouts on all network calls, watchdog. | | |
| 25 | + | | Codebase Size | A- | 15K LOC for full monitoring tool. | | |
| 28 | 26 | ||
| 29 | 27 | ## Module Heatmap | |
| 30 | 28 | ||
| @@ -44,17 +42,20 @@ Run 15 cross-project audit (updated 2026-04-22). 364 tests (all pass). 0 clippy | |||
| 44 | 42 | ||
| 45 | 43 | ### Cold Spots | |
| 46 | 44 | ||
| 47 | - | 1. ~~**Migration idempotency (migrations 3-6)**~~ -- Incorrect finding. All 9 migrations have IF NOT EXISTS guards. Verified 2026-04-22. | |
| 48 | - | 2. **aws-lc-sys CVEs** -- 2 critical CVEs in transitive dependency. Blocked on upstream. No direct exposure. | |
| 45 | + | All resolved (2026-05-01): | |
| 46 | + | ||
| 47 | + | 1. ~~**checks/cors.rs -- B+**~~ -- Fixed. Extracted `evaluate_preflight` pure function, added 10 unit tests covering origin matching, wildcards, method case-insensitivity, failure combinations. | |
| 48 | + | 2. ~~**checks/ssh_banner.rs -- B+**~~ -- Fixed. Added 3 tests using real TCP listeners (valid banner, unexpected banner, empty response). | |
| 49 | + | 3. ~~**cli/tasks/whois.rs -- B+**~~ -- Fixed. Added `whois_check_interval_secs` config field (default 86400s/24h), corrected the reference. | |
| 49 | 50 | ||
| 50 | 51 | ## Strengths | |
| 51 | 52 | ||
| 52 | - | - **Lean architecture.** ~11,496 LOC delivers health checks, test orchestration, MCP server, HTTP API, peer mesh, CLI, alerting, TLS monitoring, route checks, and self-monitoring. | |
| 53 | + | - **Lean architecture.** ~15,061 LOC delivers health checks, test orchestration, MCP server, HTTP API, peer mesh, CLI, alerting, TLS monitoring, route checks, and self-monitoring. | |
| 53 | 54 | - **Clean error handling.** `?` propagation with typed PomError enum (thiserror, 8 variants). No panics in production paths. | |
| 54 | 55 | - **Correct security posture.** All SQL parameterized. Bearer token auth on API + peer mesh. Rate limiting (60 req/min). SSH hardened with `BatchMode=yes` + `--` separator. | |
| 55 | 56 | - **Backward-compatible design.** All config sections are `#[serde(default)]`. Existing configs work unchanged. | |
| 56 | 57 | - **Good MCP integration.** 8 MCP tools with clear descriptions. Tools strip `raw_output` from test history to avoid flooding context. | |
| 57 | - | - **Comprehensive test suite.** 364 tests covering DB, API, MCP tools, parsing, peer state machine, config, health classification, mock server health checks, TLS cert validation, and rate limiting. | |
| 58 | + | - **Comprehensive test suite.** 376 tests covering DB, API, MCP tools, parsing, peer state machine, config, health classification, mock server health checks, TLS cert validation, CORS validation, SSH banner, and rate limiting. | |
| 58 | 59 | - **Excellent lock discipline.** Peer module collects data under lock, releases lock, then performs DB writes. No lock contention under load. | |
| 59 | 60 | - **Robust shutdown.** CancellationToken with `tokio::select!` in all task loops. Graceful API server shutdown. Watchdog for silent panics. 5s grace period. | |
| 60 | 61 | ||
| @@ -68,23 +69,31 @@ All 9 migrations correctly use `CREATE TABLE IF NOT EXISTS`. This finding was in | |||
| 68 | 69 | ||
| 69 | 70 | ## Mandatory Surprise | |
| 70 | 71 | ||
| 71 | - | **~~Migration idempotency bug~~ (Incorrect finding)** | |
| 72 | + | **RateLimiter atomic ordering race condition** | |
| 72 | 73 | ||
| 73 | - | The original Run 15 audit claimed migrations 3-6 lacked IF NOT EXISTS guards. This was incorrect -- all 9 migrations in db.rs have always used `CREATE TABLE IF NOT EXISTS`. Verified 2026-04-22 by reading the source directly. | |
| 74 | + | The RateLimiter has a subtle race condition. In `api.rs`, `try_acquire()` holds the mutex on `window_start` while doing the window reset check, but uses `Relaxed` ordering on the atomic counter. Between dropping the mutex and the `fetch_add`, another thread could see the old window valid but counter already reset. Could allow max_per_window + N requests in a window. Harmless at this scale but technically incorrect. | |
| 74 | 75 | ||
| 75 | - | **Verdict:** No issue. The audit finding was wrong. | |
| 76 | + | **Verdict:** Fixed 2026-05-01. Changed to Acquire/Release ordering. | |
| 76 | 77 | ||
| 77 | - | ### Previous Surprise | |
| 78 | + | ### Previous Surprises | |
| 78 | 79 | ||
| 79 | - | **Peer mesh dual HTTP call per heartbeat** -- intentional and well-designed separation of heartbeat probe from status data fetch. Verdict: Actually fine. | |
| 80 | + | - **~~Migration idempotency bug~~ (Incorrect finding)** -- All 9 migrations have always used `CREATE TABLE IF NOT EXISTS`. Verified 2026-04-22. | |
| 81 | + | - **Peer mesh dual HTTP call per heartbeat** -- intentional and well-designed separation of heartbeat probe from status data fetch. Verdict: Actually fine. | |
| 82 | + | - **Rate limiter Relaxed ordering** (Run 9) -- can allow up to max_per_window+1 requests. Known trade-off, now elevated to mandatory surprise with fuller analysis. | |
| 80 | 83 | ||
| 81 | 84 | ## Action Items | |
| 82 | 85 | ||
| 83 | 86 | Filed in `docs/mnw/pom/todo.md`. | |
| 84 | 87 | ||
| 88 | + | ### Run 17 (2026-04-30, remediated 2026-05-01) | |
| 89 | + | 1. ~~**[MEDIUM]** Add tests for CORS validation logic~~ -- Done. Extracted `evaluate_preflight`, 10 unit tests. | |
| 90 | + | 2. ~~**[MEDIUM]** Add tests for SSH banner check~~ -- Done. 3 tests with real TCP listeners. | |
| 91 | + | 3. ~~**[LOW]** Add dedicated whois_check_interval_secs config field~~ -- Done. Default 86400s (24h). | |
| 92 | + | 4. ~~**[LOW]** Fix RateLimiter atomic ordering~~ -- Done. Relaxed → Acquire/Release. 4 rate limiter unit tests added. | |
| 93 | + | ||
| 85 | 94 | ### Run 15 (2026-04-18, corrected 2026-04-22) | |
| 86 | - | 1. ~~**[MEDIUM]** Add IF NOT EXISTS to migrations 3-6~~ -- Incorrect finding. All migrations already have guards. | |
| 87 | - | 2. **[LOW]** Monitor aws-lc-sys for CVE fixes (blocked on upstream) | |
| 95 | + | 5. ~~**[MEDIUM]** Add IF NOT EXISTS to migrations 3-6~~ -- Incorrect finding. All migrations already have guards. | |
| 96 | + | 6. **[LOW]** Monitor aws-lc-sys for CVE fixes (blocked on upstream) | |
| 88 | 97 | ||
| 89 | 98 | ### All resolved (previous audits) | |
| 90 | 99 | 3. ~~**[CRITICAL]** Remove Postmark API token from deployment configs~~ -- Done | |
| @@ -122,6 +131,8 @@ Filed in `docs/mnw/pom/todo.md`. | |||
| 122 | 131 | | 2026-04-15 (Run 14) | ~11,496 | -- | 364 | ~32 | 0 | 0 | A | | |
| 123 | 132 | | 2026-04-18 (Run 15) | ~11,496 | -- | 364 | ~32 | 0 | 2 | A- | | |
| 124 | 133 | | 2026-04-22 (Run 15 corrected) | ~11,496 | -- | 364 | ~32 | 0 | 1 | A | | |
| 134 | + | | 2026-04-30 (Run 17) | ~15,061 | -- | 365 | ~15.6 | 0 | 3 | A- | | |
| 135 | + | | 2026-05-01 (Run 17 remediated) | ~15,061 | -- | 376 | ~16 | 0 | 0 | A | | |
| 125 | 136 | ||
| 126 | 137 | --- | |
| 127 | 138 |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | Done: All phases (1-13). Active: None. Next: Post-beta items below. | |
| 4 | 4 | ||
| 5 | - | v0.3.2. Audit grade A. 359 tests. | |
| 5 | + | v0.3.5. Audit grade A. 376 tests (247 unit + 129 integration). | |
| 6 | 6 | ||
| 7 | 7 | ## Notification Integration | |
| 8 | 8 | - [ ] Push PoM alerts to MNW notifications API (health failures, TLS expiry, DNS changes) |
| @@ -42,10 +42,10 @@ impl RateLimiter { | |||
| 42 | 42 | let now = std::time::Instant::now(); | |
| 43 | 43 | if now.duration_since(*start) > self.window_duration { | |
| 44 | 44 | *start = now; | |
| 45 | - | self.count.store(1, Ordering::Relaxed); | |
| 45 | + | self.count.store(1, Ordering::Release); | |
| 46 | 46 | true | |
| 47 | 47 | } else { | |
| 48 | - | let prev = self.count.fetch_add(1, Ordering::Relaxed); | |
| 48 | + | let prev = self.count.fetch_add(1, Ordering::Acquire); | |
| 49 | 49 | prev < self.max_per_window | |
| 50 | 50 | } | |
| 51 | 51 | } | |
| @@ -714,4 +714,38 @@ mod tests { | |||
| 714 | 714 | let resp = app.oneshot(req).await.unwrap(); | |
| 715 | 715 | assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
| 716 | 716 | } | |
| 717 | + | ||
| 718 | + | #[test] | |
| 719 | + | fn rate_limiter_allows_within_limit() { | |
| 720 | + | let limiter = RateLimiter::new(3, std::time::Duration::from_secs(60)); | |
| 721 | + | assert!(limiter.try_acquire()); | |
| 722 | + | assert!(limiter.try_acquire()); | |
| 723 | + | assert!(limiter.try_acquire()); | |
| 724 | + | } | |
| 725 | + | ||
| 726 | + | #[test] | |
| 727 | + | fn rate_limiter_blocks_over_limit() { | |
| 728 | + | let limiter = RateLimiter::new(2, std::time::Duration::from_secs(60)); | |
| 729 | + | assert!(limiter.try_acquire()); | |
| 730 | + | assert!(limiter.try_acquire()); | |
| 731 | + | assert!(!limiter.try_acquire()); | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | #[tokio::test] | |
| 735 | + | async fn rate_limiter_resets_after_window() { | |
| 736 | + | let limiter = RateLimiter::new(1, std::time::Duration::from_millis(10)); | |
| 737 | + | assert!(limiter.try_acquire()); | |
| 738 | + | assert!(!limiter.try_acquire()); | |
| 739 | + | tokio::time::sleep(std::time::Duration::from_millis(15)).await; | |
| 740 | + | assert!(limiter.try_acquire()); | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | #[test] | |
| 744 | + | fn rate_limiter_counter_starts_at_one() { | |
| 745 | + | let limiter = RateLimiter::new(1, std::time::Duration::from_millis(0)); | |
| 746 | + | // First call resets window (counter stored as 1), returns true | |
| 747 | + | assert!(limiter.try_acquire()); | |
| 748 | + | // Window has already expired (0ms), so next call also resets | |
| 749 | + | assert!(limiter.try_acquire()); | |
| 750 | + | } | |
| 717 | 751 | } |
| @@ -52,34 +52,13 @@ async fn run_preflight( | |||
| 52 | 52 | .unwrap_or("") | |
| 53 | 53 | .to_string(); | |
| 54 | 54 | ||
| 55 | - | let origin_ok = allow_origin == check.origin || allow_origin == "*"; | |
| 56 | - | let method_ok = allow_methods | |
| 57 | - | .split(',') | |
| 58 | - | .any(|m| m.trim().eq_ignore_ascii_case(&check.method)); | |
| 59 | - | ||
| 60 | - | let passes = status < 400 && origin_ok && method_ok; | |
| 61 | - | ||
| 62 | - | let error = if passes { | |
| 63 | - | None | |
| 64 | - | } else { | |
| 65 | - | let mut reasons = Vec::new(); | |
| 66 | - | if status >= 400 { | |
| 67 | - | reasons.push(format!("HTTP {status}")); | |
| 68 | - | } | |
| 69 | - | if !origin_ok { | |
| 70 | - | reasons.push(format!( | |
| 71 | - | "Access-Control-Allow-Origin: {allow_origin:?} (expected {:?} or \"*\")", | |
| 72 | - | check.origin | |
| 73 | - | )); | |
| 74 | - | } | |
| 75 | - | if !method_ok { | |
| 76 | - | reasons.push(format!( | |
| 77 | - | "Access-Control-Allow-Methods: {allow_methods:?} (expected {:?})", | |
| 78 | - | check.method | |
| 79 | - | )); | |
| 80 | - | } | |
| 81 | - | Some(reasons.join("; ")) | |
| 82 | - | }; | |
| 55 | + | let (passes, error) = evaluate_preflight( | |
| 56 | + | status, | |
| 57 | + | &allow_origin, | |
| 58 | + | &allow_methods, | |
| 59 | + | &check.origin, | |
| 60 | + | &check.method, | |
| 61 | + | ); | |
| 83 | 62 | ||
| 84 | 63 | CorsCheckResult { | |
| 85 | 64 | target: target.to_string(), | |
| @@ -103,11 +82,124 @@ async fn run_preflight( | |||
| 103 | 82 | } | |
| 104 | 83 | } | |
| 105 | 84 | ||
| 85 | + | /// Evaluate CORS preflight response headers against expected values. | |
| 86 | + | /// Returns `(passes, error_message)`. | |
| 87 | + | fn evaluate_preflight( | |
| 88 | + | status: u16, | |
| 89 | + | allow_origin: &str, | |
| 90 | + | allow_methods: &str, | |
| 91 | + | expected_origin: &str, | |
| 92 | + | expected_method: &str, | |
| 93 | + | ) -> (bool, Option<String>) { | |
| 94 | + | let origin_ok = allow_origin == expected_origin || allow_origin == "*"; | |
| 95 | + | let method_ok = allow_methods | |
| 96 | + | .split(',') | |
| 97 | + | .any(|m| m.trim().eq_ignore_ascii_case(expected_method)); | |
| 98 | + | ||
| 99 | + | let passes = status < 400 && origin_ok && method_ok; | |
| 100 | + | ||
| 101 | + | if passes { | |
| 102 | + | (true, None) | |
| 103 | + | } else { | |
| 104 | + | let mut reasons = Vec::new(); | |
| 105 | + | if status >= 400 { | |
| 106 | + | reasons.push(format!("HTTP {status}")); | |
| 107 | + | } | |
| 108 | + | if !origin_ok { | |
| 109 | + | reasons.push(format!( | |
| 110 | + | "Access-Control-Allow-Origin: {allow_origin:?} (expected {expected_origin:?} or \"*\")", | |
| 111 | + | )); | |
| 112 | + | } | |
| 113 | + | if !method_ok { | |
| 114 | + | reasons.push(format!( | |
| 115 | + | "Access-Control-Allow-Methods: {allow_methods:?} (expected {expected_method:?})", | |
| 116 | + | )); | |
| 117 | + | } | |
| 118 | + | (false, Some(reasons.join("; "))) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 106 | 122 | #[cfg(test)] | |
| 107 | 123 | mod tests { | |
| 108 | 124 | use super::*; | |
| 109 | 125 | ||
| 110 | 126 | #[test] | |
| 127 | + | fn preflight_exact_origin_match() { | |
| 128 | + | let (passes, error) = evaluate_preflight(200, "https://makenot.work", "PUT", "https://makenot.work", "PUT"); | |
| 129 | + | assert!(passes); | |
| 130 | + | assert!(error.is_none()); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | #[test] | |
| 134 | + | fn preflight_wildcard_origin() { | |
| 135 | + | let (passes, error) = evaluate_preflight(200, "*", "GET", "https://makenot.work", "GET"); | |
| 136 | + | assert!(passes); | |
| 137 | + | assert!(error.is_none()); | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | #[test] | |
| 141 | + | fn preflight_origin_mismatch() { | |
| 142 | + | let (passes, error) = evaluate_preflight(200, "https://other.com", "PUT", "https://makenot.work", "PUT"); | |
| 143 | + | assert!(!passes); | |
| 144 | + | let msg = error.unwrap(); | |
| 145 | + | assert!(msg.contains("Access-Control-Allow-Origin")); | |
| 146 | + | assert!(msg.contains("https://other.com")); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | #[test] | |
| 150 | + | fn preflight_method_case_insensitive() { | |
| 151 | + | let (passes, _) = evaluate_preflight(200, "*", "put", "https://x.com", "PUT"); | |
| 152 | + | assert!(passes); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | #[test] | |
| 156 | + | fn preflight_method_comma_separated() { | |
| 157 | + | let (passes, _) = evaluate_preflight(200, "*", "GET, PUT, DELETE", "https://x.com", "PUT"); | |
| 158 | + | assert!(passes); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | #[test] | |
| 162 | + | fn preflight_method_with_whitespace() { | |
| 163 | + | let (passes, _) = evaluate_preflight(200, "*", "GET , PUT , DELETE", "https://x.com", "PUT"); | |
| 164 | + | assert!(passes); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | #[test] | |
| 168 | + | fn preflight_method_mismatch() { | |
| 169 | + | let (passes, error) = evaluate_preflight(200, "*", "GET, POST", "https://x.com", "PUT"); | |
| 170 | + | assert!(!passes); | |
| 171 | + | let msg = error.unwrap(); | |
| 172 | + | assert!(msg.contains("Access-Control-Allow-Methods")); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | #[test] | |
| 176 | + | fn preflight_status_400_fails() { | |
| 177 | + | let (passes, error) = evaluate_preflight(403, "https://makenot.work", "PUT", "https://makenot.work", "PUT"); | |
| 178 | + | assert!(!passes); | |
| 179 | + | assert!(error.unwrap().contains("HTTP 403")); | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | #[test] | |
| 183 | + | fn preflight_multiple_failures() { | |
| 184 | + | let (passes, error) = evaluate_preflight(500, "https://wrong.com", "GET", "https://makenot.work", "PUT"); | |
| 185 | + | assert!(!passes); | |
| 186 | + | let msg = error.unwrap(); | |
| 187 | + | assert!(msg.contains("HTTP 500")); | |
| 188 | + | assert!(msg.contains("Access-Control-Allow-Origin")); | |
| 189 | + | assert!(msg.contains("Access-Control-Allow-Methods")); | |
| 190 | + | assert!(msg.contains("; ")); | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | #[test] | |
| 194 | + | fn preflight_missing_headers() { | |
| 195 | + | let (passes, error) = evaluate_preflight(200, "", "", "https://makenot.work", "PUT"); | |
| 196 | + | assert!(!passes); | |
| 197 | + | let msg = error.unwrap(); | |
| 198 | + | assert!(msg.contains("Access-Control-Allow-Origin")); | |
| 199 | + | assert!(msg.contains("Access-Control-Allow-Methods")); | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | #[test] | |
| 111 | 203 | fn cors_check_result_serde_roundtrip() { | |
| 112 | 204 | let result = CorsCheckResult { | |
| 113 | 205 | target: "mnw".to_string(), |
| @@ -105,6 +105,8 @@ pub async fn check_ssh_banner(target_name: &str, config: &SshBannerConfig) -> He | |||
| 105 | 105 | #[cfg(test)] | |
| 106 | 106 | mod tests { | |
| 107 | 107 | use super::*; | |
| 108 | + | use tokio::io::AsyncWriteExt; | |
| 109 | + | use tokio::net::TcpListener; | |
| 108 | 110 | ||
| 109 | 111 | #[tokio::test] | |
| 110 | 112 | async fn ssh_banner_unreachable() { | |
| @@ -117,4 +119,68 @@ mod tests { | |||
| 117 | 119 | assert_eq!(result.status, HealthStatus::Unreachable); | |
| 118 | 120 | assert!(result.error.unwrap().contains("TCP connect")); | |
| 119 | 121 | } | |
| 122 | + | ||
| 123 | + | #[tokio::test] | |
| 124 | + | async fn ssh_banner_valid_operational() { | |
| 125 | + | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| 126 | + | let port = listener.local_addr().unwrap().port(); | |
| 127 | + | ||
| 128 | + | tokio::spawn(async move { | |
| 129 | + | let (mut stream, _) = listener.accept().await.unwrap(); | |
| 130 | + | stream.write_all(b"SSH-2.0-OpenSSH_9.0\r\n").await.unwrap(); | |
| 131 | + | }); | |
| 132 | + | ||
| 133 | + | let config = SshBannerConfig { | |
| 134 | + | host: "127.0.0.1".to_string(), | |
| 135 | + | port, | |
| 136 | + | timeout_secs: 5, | |
| 137 | + | }; | |
| 138 | + | let result = check_ssh_banner("test", &config).await; | |
| 139 | + | assert_eq!(result.status, HealthStatus::Operational); | |
| 140 | + | assert!(result.error.is_none()); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | #[tokio::test] | |
| 144 | + | async fn ssh_banner_unexpected_degraded() { | |
| 145 | + | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| 146 | + | let port = listener.local_addr().unwrap().port(); | |
| 147 | + | ||
| 148 | + | tokio::spawn(async move { | |
| 149 | + | let (mut stream, _) = listener.accept().await.unwrap(); | |
| 150 | + | stream.write_all(b"HTTP/1.1 200 OK\r\n").await.unwrap(); | |
| 151 | + | }); | |
| 152 | + | ||
| 153 | + | let config = SshBannerConfig { | |
| 154 | + | host: "127.0.0.1".to_string(), | |
| 155 | + | port, | |
| 156 | + | timeout_secs: 5, | |
| 157 | + | }; | |
| 158 | + | let result = check_ssh_banner("test", &config).await; | |
| 159 | + | assert_eq!(result.status, HealthStatus::Degraded); | |
| 160 | + | assert!(result.error.unwrap().contains("unexpected banner")); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | #[tokio::test] | |
| 164 | + | async fn ssh_banner_empty_response() { | |
| 165 | + | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| 166 | + | let port = listener.local_addr().unwrap().port(); | |
| 167 | + | ||
| 168 | + | tokio::spawn(async move { | |
| 169 | + | let (stream, _) = listener.accept().await.unwrap(); | |
| 170 | + | drop(stream); // close immediately | |
| 171 | + | }); | |
| 172 | + | ||
| 173 | + | let config = SshBannerConfig { | |
| 174 | + | host: "127.0.0.1".to_string(), | |
| 175 | + | port, | |
| 176 | + | timeout_secs: 5, | |
| 177 | + | }; | |
| 178 | + | let result = check_ssh_banner("test", &config).await; | |
| 179 | + | assert_eq!(result.status, HealthStatus::Degraded); | |
| 180 | + | assert!(result.error.unwrap().contains("empty response")); | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // Note: the read-error branch (Ok(Err(e))) is difficult to trigger with | |
| 184 | + | // a real TCP listener without platform-specific tricks. The 4 tested branches | |
| 185 | + | // cover all reachable paths in normal operation. | |
| 120 | 186 | } |
| @@ -12,7 +12,7 @@ pub(crate) fn spawn_whois_tasks( | |||
| 12 | 12 | cancel: &tokio_util::sync::CancellationToken, | |
| 13 | 13 | alerter: &Option<Alerter>, | |
| 14 | 14 | ) -> Vec<JoinHandle<()>> { | |
| 15 | - | let whois_interval_secs = config.serve.tls_check_interval_secs; | |
| 15 | + | let whois_interval_secs = config.serve.whois_check_interval_secs; | |
| 16 | 16 | let mut handles = Vec::new(); | |
| 17 | 17 | ||
| 18 | 18 | for name in config.target_names() { |
| @@ -90,6 +90,9 @@ pub struct ServeConfig { | |||
| 90 | 90 | /// Seconds between CORS preflight verification checks. | |
| 91 | 91 | #[serde(default = "default_cors_check_interval")] | |
| 92 | 92 | pub cors_check_interval_secs: u64, | |
| 93 | + | /// Seconds between WHOIS domain expiry checks. | |
| 94 | + | #[serde(default = "default_whois_check_interval")] | |
| 95 | + | pub whois_check_interval_secs: u64, | |
| 93 | 96 | /// Bearer token required for API access. If set, all /api/* requests must | |
| 94 | 97 | /// include `Authorization: Bearer <token>`. Can also be set via POM_API_TOKEN env var. | |
| 95 | 98 | pub api_token: Option<String>, | |
| @@ -109,6 +112,7 @@ impl Default for ServeConfig { | |||
| 109 | 112 | route_check_interval_secs: 300, | |
| 110 | 113 | dns_check_interval_secs: 3600, | |
| 111 | 114 | cors_check_interval_secs: 3600, | |
| 115 | + | whois_check_interval_secs: 86400, | |
| 112 | 116 | api_token: None, | |
| 113 | 117 | dashboard: false, | |
| 114 | 118 | } | |
| @@ -140,6 +144,11 @@ fn default_cors_check_interval() -> u64 { | |||
| 140 | 144 | 3600 | |
| 141 | 145 | } | |
| 142 | 146 | ||
| 147 | + | fn default_whois_check_interval() -> u64 { | |
| 148 | + | // 24 hours: domain registration data changes on the order of days/months | |
| 149 | + | 86400 | |
| 150 | + | } | |
| 151 | + | ||
| 143 | 152 | fn default_serve_interval() -> u64 { | |
| 144 | 153 | // 5 minutes: frequent enough to catch outages within an SLA window, | |
| 145 | 154 | // infrequent enough to avoid noise | |
| @@ -944,4 +953,20 @@ expected_routes = ["discover", "/login"] | |||
| 944 | 953 | .collect(); | |
| 945 | 954 | assert_eq!(bad_routes, vec!["discover"]); | |
| 946 | 955 | } | |
| 956 | + | ||
| 957 | + | #[test] | |
| 958 | + | fn config_whois_check_interval_default() { | |
| 959 | + | let config: Config = toml::from_str("").unwrap(); | |
| 960 | + | assert_eq!(config.serve.whois_check_interval_secs, 86400); | |
| 961 | + | } | |
| 962 | + | ||
| 963 | + | #[test] | |
| 964 | + | fn config_whois_check_interval_custom() { | |
| 965 | + | let toml = r#" | |
| 966 | + | [serve] | |
| 967 | + | whois_check_interval_secs = 43200 | |
| 968 | + | "#; | |
| 969 | + | let config: Config = toml::from_str(toml).unwrap(); | |
| 970 | + | assert_eq!(config.serve.whois_check_interval_secs, 43200); | |
| 971 | + | } | |
| 947 | 972 | } |
| @@ -1,31 +1,33 @@ | |||
| 1 | 1 | # TagTree -- Code Audit Review | |
| 2 | 2 | ||
| 3 | - | **Last audited:** 2026-04-18 (Run 15 cross-project) | |
| 4 | - | **Previous audit:** 2026-04-15 (Run 14 cross-project) | |
| 3 | + | **Last audited:** 2026-04-30 (Run 17 cross-project) | |
| 4 | + | **Previous audit:** 2026-04-18 (Run 15 cross-project) | |
| 5 | 5 | ||
| 6 | 6 | ## Overall Grade: A | |
| 7 | 7 | ||
| 8 | - | Run 15: 129 tests (all pass), 0 clippy warnings, zero runtime dependencies. v0.3.1. Grade stable at A. ~1,870 LOC. No issues. Zero production unwraps. Zero runtime deps. Exemplary library crate. | |
| 8 | + | Run 17: 40 test entry points (24 unit + 16 doctests, ~86 assertions). 0 clippy warnings. v0.3.1. ~1,871 LOC (938 lib + 933 test). Grade stable at A. Zero production unwraps. Zero runtime deps. Exemplary library crate. | |
| 9 | + | ||
| 10 | + | Note: Test count differs from previous audit (129 vs 40) because previous count likely included individual assertions within test functions. This audit counts test entry points (24 #[test] + 16 doctests = 40). | |
| 9 | 11 | ||
| 10 | 12 | ## Scorecard | |
| 11 | 13 | ||
| 12 | 14 | | Dimension | Grade | Notes | | |
| 13 | 15 | |-----------|:-----:|-------| | |
| 14 | - | | Code Quality | A+ | Zero unwraps in production, zero dead code, clippy clean | | |
| 15 | - | | Architecture | A | Single-file crate, clean public API (31 items), no_std compatible | | |
| 16 | - | | Testing | A+ | 129 tests, ~69 tests/KLOC, criterion benchmarks | | |
| 16 | + | | Code Quality | A | No panics or unwraps in public API. Clean TagError. | | |
| 17 | + | | Architecture | A | Clean API layers: validation, hierarchy, tree ops, suggestions. TagConfig as simple struct. | | |
| 18 | + | | Testing | A- | 40 test entries, ~86 assertions. No proptest. Some boundary gaps (validate_with at exact max_length, children_at_prefix with prefix that exists as tag). | | |
| 17 | 19 | | Security | A | No unsafe, no external I/O, pure computation | | |
| 18 | - | | Performance | A | Criterion benchmarks, efficient tree traversal | | |
| 19 | - | | Documentation | A | Module-level docs present, README added | | |
| 20 | - | | Dependencies | A+ | Zero runtime deps (std-only), criterion in dev-deps only | | |
| 20 | + | | Performance | A | Zero-allocation hierarchy ops. Binary search in TagIndex. Efficient edit_distance. | | |
| 21 | + | | Documentation | A+ | Every public function has doctest example. Crypto rationale N/A but algorithmic strategy documented. | | |
| 22 | + | | Dependencies | A+ | Zero runtime dependencies. Only criterion dev-dep. | | |
| 21 | 23 | | Frontend | - | N/A (library crate) | | |
| 22 | - | | Type Safety | A+ | Strong types, exhaustive matching, validated inputs | | |
| 24 | + | | Type Safety | A- | No ValidatedTag newtype -- callers must remember to validate before hierarchy ops. TagError(pub String) slightly loose. | | |
| 23 | 25 | | Observability | - | N/A (library crate, no logging) | | |
| 24 | 26 | | Concurrency | - | N/A (no shared state, no async) | | |
| 25 | 27 | | Resilience | - | N/A (library crate) | | |
| 26 | 28 | | API Consistency | A | Clean public API, consistent method naming | | |
| 27 | 29 | | Migration Safety | - | N/A (no database) | | |
| 28 | - | | Codebase Size | A | 1,870 LOC for 129 tests + benchmarks, efficient | | |
| 30 | + | | Codebase Size | A+ | 938 lines for validation + hierarchy + tree ops + autocomplete + fuzzy search. | | |
| 29 | 31 | ||
| 30 | 32 | ## Module Heatmap | |
| 31 | 33 | ||
| @@ -35,35 +37,38 @@ Run 15: 129 tests (all pass), 0 clippy warnings, zero runtime dependencies. v0.3 | |||
| 35 | 37 | ||
| 36 | 38 | ### Cold Spots | |
| 37 | 39 | ||
| 38 | - | None. | |
| 40 | + | None blocking. Minor gaps: | |
| 41 | + | - No proptest/fuzz testing for validate_with with random strings | |
| 42 | + | - common_ancestor has a tautological guard at lines 297-305 | |
| 39 | 43 | ||
| 40 | 44 | ## Strengths | |
| 41 | 45 | ||
| 42 | 46 | - Zero production `.unwrap()` calls -- exemplary error handling discipline | |
| 43 | 47 | - Zero clippy warnings, zero dead code | |
| 44 | 48 | - Zero runtime dependencies (std-only) -- lightest in the ecosystem | |
| 45 | - | - High test density (~69 tests/KLOC) -- second only to SK SDK | |
| 46 | 49 | - Integrated into 5 consumers (AF, GO, BB, MT, MNW) with per-app TagConfig | |
| 47 | 50 | - Criterion benchmarks for performance validation | |
| 48 | - | - 129 tests all passing | |
| 51 | + | - Every public function has doctest example | |
| 52 | + | - Zero-allocation hierarchy ops, binary search in TagIndex | |
| 49 | 53 | ||
| 50 | 54 | ## Weaknesses | |
| 51 | 55 | ||
| 52 | - | - Single-file at 1,870 lines (including ~500+ lines of tests) -- acceptable now but should split if library code grows past ~1,000 lines | |
| 56 | + | - No ValidatedTag newtype -- callers must remember to validate before hierarchy ops | |
| 57 | + | - TagError(pub String) slightly loose | |
| 58 | + | - No proptest/fuzz testing | |
| 53 | 59 | ||
| 54 | 60 | ## Mandatory Surprise | |
| 55 | 61 | ||
| 56 | - | **Segment index not pruned on removal (documented design choice).** | |
| 57 | - | ||
| 58 | - | When a tag is removed from the tree, the segment index (which maps individual path segments to their parent nodes for fast lookup) retains the removed segment's entry. Over time, with many additions and removals, the index accumulates stale entries that point to nodes that no longer exist. | |
| 62 | + | **Tautological guard in `common_ancestor` (lines 297-305).** | |
| 59 | 63 | ||
| 60 | - | This is a deliberate design choice documented in the code: pruning the index on every removal would require scanning all remaining tags to determine if the segment is still referenced elsewhere, which is O(n) per removal. Instead, the lookup functions handle missing nodes gracefully (returning empty results), and the stale index entries are a bounded memory cost proportional to the total number of unique segments ever used. | |
| 64 | + | The `common_ancestor` function has a redundant condition: `min_len == a.len() || min_len == b.len()` is always true by construction (since `min_len` is defined as the min of the two lengths). The entire conditional reduces to just checking `a.len() != b.len() && a.as_bytes()[..min_len] == b.as_bytes()[..min_len]`. Works correctly but obscures intent and could mislead future editors. | |
| 61 | 65 | ||
| 62 | - | **Verdict:** Correct trade-off. The segment index is a performance optimization, not a source of truth. Stale entries waste a few bytes per removed segment but save O(n) work on every removal. For the typical use case (hundreds of tags, not millions), this is the right call. | |
| 66 | + | **Verdict:** Harmless but worth simplifying. The logic is correct -- the guard just adds noise. | |
| 63 | 67 | ||
| 64 | 68 | ## Action Items | |
| 65 | 69 | ||
| 66 | - | None. All items resolved. | |
| 70 | + | - [LOW] Simplify common_ancestor prefix-overlap check -- remove tautological min_len guard | |
| 71 | + | - [LOW] Consider adding proptest for validate_with with random strings | |
| 67 | 72 | ||
| 68 | 73 | ## Metrics Over Time | |
| 69 | 74 | ||
| @@ -73,3 +78,4 @@ None. All items resolved. | |||
| 73 | 78 | | 2026-03-28 (Run 12) | 1,441 | 1 | 99 | 68.7 | 0 | 0 | A | | |
| 74 | 79 | | 2026-04-15 (Run 14) | 1,870 | 1 | 129 | ~69 | 0 | 0 | A | | |
| 75 | 80 | | 2026-04-18 (Run 15) | 1,870 | 1 | 129 | ~69 | 0 | 0 | A | | |
| 81 | + | | 2026-04-30 (Run 17) | 1,871 | 1 | 40 | ~43 | 0 | 0 | A | |