## Test Coverage (adversarial)

Audit scope: v1.30.0. Skipped findings already triaged in `docs/audit-triage-v1.30.0.md` (the v1.29.0 carryover triage). Focus is on surfaces *added or substantially changed* in v1.29.1..v1.30.0 — slots/cache (#1105), local LLM provider (#1101), per-launch serve auth (#1118), non-blocking HNSW rebuild (#1113), `nomic-coderank` preset (#1110), execution-provider feature split (#1120) — plus pre-existing untested adversarial paths.

#### TC-ADV-1.30-1: `LocalProvider` body-size DoS — `body_preview` and `parse_choices_content` both buffer entire HTTP response before truncating
- **Difficulty:** medium
- **Location:** `src/llm/local.rs:474-488` (`parse_choices_content`), `src/llm/local.rs:490-500` (`body_preview`). Tests in `src/llm/local.rs:595+`.
- **Description:** `parse_choices_content` calls `resp.json::<Value>()` which reads the entire response body into memory before parsing. `body_preview` calls `resp.text()` which does the same, then char-truncates to 256 bytes. The `reqwest::blocking::Client` constructed at `src/llm/local.rs:97-100` sets `timeout` and `redirect` only — there is **no body-size limit**. A misconfigured / hostile / panicked OpenAI-compat server (or any server reachable at the configured `api_base`) can return an arbitrarily large 200-OK or 4xx body and OOM the daemon's blocking-pool thread. The local LLM concurrency knob clamps at 64, so up to 64 concurrent unbounded reads can race for memory before any one completes. Existing test `long_response_not_truncated` at `:826-847` only exercises the 100 KB happy path and *requires* the full body to come through — locking in the unbounded read as a contract.
- **Suggested fix:** Add to the `tests` module in `src/llm/local.rs`:
  - `test_oversized_response_body_capped_at_5mb` — mock a 200-OK with a 50 MB JSON body; assert the worker either errors out (preferred) or reads at most a documented cap (e.g. 5 MB). Implementation: wrap the response with `Response::bytes_stream` / `take` or set `reqwest::Client::builder().…response_body_limit(…)` (helper exists via `read_to_end_with_limit`).
  - `test_4xx_with_large_body_does_not_buffer_entire_body` — mock a 400 with a 50 MB body; assert `body_preview` returns ≤ 256 bytes *and* never allocates more than e.g. 4 KB by checking with a tracking allocator or measuring `resp.bytes_stream().next().await`.
  - Recommended alongside: cap response body via `reqwest::Body::limit` or `Response::bytes_stream` chunking. Without a fix, an A6000 box's 64-thread blocking pool × N-MB body = trivial OOM crater.

#### TC-ADV-1.30-2: `EmbeddingCache::write_batch` and `QueryCache::put` accept NaN/Inf embeddings — cross-process cache poisoning
- **Difficulty:** easy
- **Location:** `src/cache.rs:332-407` (`EmbeddingCache::write_batch`), `src/cache.rs:1677-1699` (`QueryCache::put`). Tests in the `tests` mod (`src/cache.rs:756+`) and `shared_runtime_tests` (`:1748+`).
- **Description:** `write_batch` validates `embedding.len() == dim` and `!embedding.is_empty()` (lines 360-373) but never checks `embedding.iter().all(|f| f.is_finite())`. Same hole in `QueryCache::put` (line 1677): bytes are flat-mapped from `embedding.as_slice()` and inserted with no finiteness check. Once a poisoned NaN/Inf entry lands on disk, *every subsequent process* that reuses the project's `embeddings_cache.db` (now project-scoped per #1105 — so cross-slot too) reads it back via `read_batch` (line 282-296) which also does not gate on finiteness, and the corrupt floats flow into HNSW search (which has a `is_finite` guard at `src/hnsw/search.rs:82`) and the brute-force / reranker scoring paths (which mostly do not). #1105 made this *worse* by making the cache long-lived per-project (was per-build before): a single bad embedder run permanently poisons the cache until the user runs `cqs cache prune`.
  - Triage note: TC-ADV-1.29-1 / TC-ADV-1.29-2 (P3) called this out at the embedder boundary; this is the *cache* boundary, which is independent and currently the only line of defense if a future embedder swap (Phase B/C of #956 — CoreML/ROCm) happens to ship NaN-on-overflow behavior different from CUDA today.
- **Suggested fix:** In `src/cache.rs::tests`:
  - `test_write_batch_rejects_nan_embedding` — write `vec![1.0, f32::NAN, 0.5, …]`; assert it is either dropped with a `tracing::warn!` or returned as a 0-count, and that a subsequent `read_batch` returns no row for that hash.
  - `test_write_batch_rejects_inf_embedding` — same for `f32::INFINITY` / `f32::NEG_INFINITY`.
  - `test_query_cache_put_rejects_non_finite` — sibling test in `query_cache_tests` (or a new mod). Build an `Embedding` via `Embedding::new` (the unchecked path; `try_new` already rejects), `put` it, then `get` it and assert the embedding either does not appear or comes back as a documented sentinel.
  - Implementation should sit alongside the existing `embedding.len() != dim` warn/skip blocks: one extra `if embedding.iter().any(|f| !f.is_finite())` early-skip with a `tracing::warn!` carrying the hash prefix.

#### TC-ADV-1.30-3: `slot_create` / `slot_remove` TOCTOU — concurrent slot operations corrupt `.cqs/slots/<name>/`
- **Difficulty:** medium
- **Location:** `src/cli/commands/infra/slot.rs:219-266` (`slot_create`), `:299-350` (`slot_remove`). Tests at `:391-516` cover sequential happy paths only.
- **Description:** Two scenarios with no test today:
  1. **Concurrent `cqs slot create foo`**: line 224 checks `dir.exists()`, line 233 calls `fs::create_dir_all(&dir)` (idempotent — does not fail). Both processes proceed past the existence check, both write `slot.toml` via `write_slot_model`. The second writer wins via the temp+rename atomicity inside `write_slot_model`, but both processes report success and the user has no signal that one was a no-op.
  2. **`cqs slot remove foo` racing `cqs index --slot foo`**: `slot_remove` calls `fs::remove_dir_all(&dir)` (line 335) without any lock. The indexer holds an open SQLite connection on `slot/foo/index.db`; on Linux the unlinking of in-use files is silent (Windows would error). The indexer continues writing to phantom inodes and, on next open, sees an empty slot. Worse: the auto-promotion at line 331 (`write_active_slot(…, &all[0])`) can flip the active pointer mid-index, so `cqs search` after the remove starts hitting an unrelated slot.
  3. **`cqs slot remove`** while daemon is serving from that slot: daemon's read-only `Store` keeps the file alive on Linux but the on-disk directory is gone — the daemon's HNSW save path (any `set_hnsw_dirty` write) silently writes to a deleted-but-open inode. After daemon restart the slot is gone.
- **Suggested fix:** Add to `src/cli/commands/infra/slot.rs::tests`:
  - `test_slot_create_concurrent_same_name` — spawn 2 threads racing `slot_create(…, "foo", Some("bge-large"), …)`, then `slot_create(…, "foo", Some("e5-base"), …)`; assert at most one returns `Ok` *or* the slot ends up with a deterministic model (whichever contract is chosen).
  - `test_slot_remove_during_open_index_db` — open `index.db` via `Store::open_readonly_pooled`, then call `slot_remove` from another thread; assert the open store either keeps working OR the remove returns an error (currently neither is guaranteed).
  - Recommend adding `flock` on `<slot>/index.db.lock` (or a new `slot.lock` in `.cqs/slots/<name>/`) acquired by both `slot_remove` and the indexer, so the second to-arrive blocks or errors instead of corrupting.

#### TC-ADV-1.30-4: Non-blocking HNSW rebuild — no test for thread panic, dim drift, or store-open failure
- **Difficulty:** medium
- **Location:** `src/cli/watch.rs:965-1042` (`spawn_hnsw_rebuild`), `:1058+` (`drain_pending_rebuild`). Existing tests at `:3979-4115` cover `delta` replay, dedup, error-clears-pending, in-flight stays-pending.
- **Description:** The rebuild thread runs `cqs::Store::open_readonly_pooled`, then `build_hnsw_index_owned`, then `build_hnsw_base_index`. Several adversarial paths are not exercised:
  - **Thread panic mid-build**: the closure at `:980-1030` is *not* wrapped in `std::panic::catch_unwind`. A panic inside `build_hnsw_index_owned` (e.g., from `unwrap` on a corrupt vector during `id_map.clone()`) unwinds the thread, which means the `let _ = tx.send(result)` at line 1029 *never runs* — the receive side hits `TryRecvError::Disconnected` on the next `drain_pending_rebuild` poll. That path *does* clear pending (line 1066-1068), so the daemon recovers. But the *delta* (chunks captured during the rebuild window) is **silently dropped** — those chunks are never inserted into any index until the next full rebuild trigger. No test covers this drop.
  - **Store dim drift**: line 985-991 explicitly checks `store.dim() != expected_dim` and bails. No test exercises this — the test fixture at `:3979` builds `state` directly, not via `spawn_hnsw_rebuild`, so the dim-drift bail path has zero coverage. After #1105 (named slots), this is *the* defense against `cqs slot promote` flipping the active slot to a different-dim model mid-rebuild.
  - **Store open failure**: line 984 `cqs::Store::open_readonly_pooled` can fail with `SQLITE_BUSY` (concurrent migration), `SQLITE_CANTOPEN` (slot dir gone — see TC-ADV-1.30-3), or schema mismatch. All collapse to `RebuildOutcome::Err`, which `drain_pending_rebuild` clears (line 1100+ in the `Err(_)` arm). Untested.
  - **Spawn-failure path** (line 1031-1036): a `Builder::spawn` failure (resource exhaustion) returns a `PendingRebuild` whose `rx` is a *fresh, unsent channel* — `try_recv` returns `Empty` forever, NOT `Disconnected`. The pending state is leaked: the watch loop thinks a rebuild is in flight indefinitely and never spawns a follow-up. **This is a real bug, not just a coverage gap** — comment says "channel will hang up on first poll" but `tx` is dropped at end of `spawn_hnsw_rebuild`'s scope only because the closure was never moved into a thread. Actually re-reading: `tx` was moved into the closure that failed to spawn, so it's dropped, and `rx.try_recv()` would return `Disconnected`. OK so the leak is closed — but the test that pins this should exist.
- **Suggested fix:** Add to `src/cli/watch.rs::tests` (alongside `drain_pending_rebuild_*`):
  - `test_spawn_hnsw_rebuild_dim_mismatch_clears_pending` — set up a `Store` with `dim=768`, call `spawn_hnsw_rebuild(…, expected_dim=1024, …)`, then `drain_pending_rebuild`; assert pending is cleared and the watch state has no dangling channel.
  - `test_spawn_hnsw_rebuild_thread_panic_drops_delta_loudly` — wrap the rebuild closure or use a temporary feature flag to inject a panic; assert the delta is *not* silently dropped (current behavior). The fix is to wrap the closure in `catch_unwind` and replay the delta into `state.hnsw_index` on panic.
  - `test_spawn_hnsw_rebuild_store_open_fails_clears_pending` — point at a non-existent index path; assert `drain_pending_rebuild` clears pending after the receiver sees the error.
  - `test_spawn_hnsw_rebuild_failure_to_spawn_disconnect_path` — synthesize spawn failure (rlimit on threads, or stub `Builder::spawn` via injection); assert a follow-up `drain_pending_rebuild` clears via `Disconnected`.

#### TC-ADV-1.30-5: `serve` auth — `strip_token_param` does not handle case variants, percent-encoded `token`, or leading `?token=` after a `?`-less path
- **Difficulty:** easy
- **Location:** `src/serve/auth.rs:101-115` (`strip_token_param`). Existing tests at `:269-291`.
- **Description:** Three cases not pinned:
  - **Case mismatch**: `?Token=abc` (capital T) — `pair.starts_with("token=")` is false, kept in the redirect URL. Browsers preserve case; some CLIs lowercase. The token won't match `ct_eq` either (token is URL-safe base64, mixed case-sensitive), so this never authenticates — but the leftover `?Token=…` in the redirect URL leaks the token into the address bar, defeating the SEC-7 design goal.
  - **Percent-encoded `token`**: `?%74oken=abc` (`%74` = `t`) — same issue, the percent-encoded prefix fails the literal `starts_with` check and the redirect carries the (still-bad) param through. RFC 3986 says `%74` and `t` are equivalent at the URI level; clients may normalize at random.
  - **Empty value `?token=`**: not tested. `pair.strip_prefix("token=")` returns `Some("")`; `ct_eq("", expected)` is false, so it falls through to `Unauthorized`. But no test pins this — a future refactor could accept empty tokens.
  - **Trailing `&` / double `&`**: `?token=abc&&depth=3` — `query.split('&')` produces an empty string between the `&&`. The empty pair fails `starts_with("token=")` and survives into the rejoined query. The redirect URL has `?depth=3` (since join uses `&`), but if the *only* other param were such an empty pair the redirect emits a stray empty query slot. Cosmetic but untested.
  - The deeper concern: `check_request` at `:158-172` only matches *literal* `token=…` in the query string, by the same `starts_with` check. So a percent-encoded `token` query param is silently treated as no token — a user pasting a URL into a browser that just happens to percent-encode `t` (Safari does this in some flows for non-ASCII surrounding context) gets a 401 for what should be a valid token. Untested.
- **Suggested fix:** In `src/serve/auth.rs::tests` (extend the existing `strip_token_param_*` set):
  - `test_strip_token_param_case_insensitive` — pin behavior on `?Token=…` (ideally: also stripped, since the auth check should be case-insensitive on param name to match HTTP convention).
  - `test_check_request_rejects_percent_encoded_token_key` — `%74oken=…` — assert 401 (current behavior) or fix to decode.
  - `test_strip_token_param_handles_double_ampersand` — `?token=abc&&depth=3` — pin redirect output.
  - Recommend a one-line fix: percent-decode the *key* using `percent_encoding::percent_decode_str` (already in the dep tree) before the prefix check, and lowercase the key for comparison. Token *value* stays exact-match for `ct_eq`.

#### TC-ADV-1.30-6: `validate_slot_name` accepts names that the OS or shell will misinterpret
- **Difficulty:** easy
- **Location:** `src/slot/mod.rs:159-178`, `src/slot/mod.rs:661+` (existing tests).
- **Description:** Pure `[a-z0-9_-]+` with max 32 chars seems safe, but two concrete cases bite:
  - **Names starting with `-`**: `validate_slot_name("-foo")` returns `Ok(())`. When passed to `cqs index --slot -foo`, clap's positional/long-flag parser will treat `-foo` as a flag, not a value to `--slot`. The user gets a confusing clap error far from the slot module. Untested.
  - **Names ending with `-`**: `"foo-"` passes. Cosmetically fine on Linux/macOS but `cqs slot remove foo-` — `cqs slot remove` accepts the name OK, but `fs::remove_dir_all(slot_dir(…, "foo-"))` then operates on `.cqs/slots/foo-/`. On Windows, trailing dashes in directory names are legal, no issue. The real problem: shell completion / `gh` URL passes / Slack mentions silently strip trailing dashes from many copy-paste paths.
  - **Names that are integer-shaped**: `"42"` passes. Then `cqs slot create 42 --model bge-large` is fine, but a future consumer that does `--slot $(cat foo)` where `foo` contains numeric data is fine — yet some shell pipelines pass numeric strings through to flag parsers as positional args by accident. Documenting that integer-shaped names are valid is enough; no current bug, but pinning the contract is cheap.
  - **Pure-underscore names**: `"_"`, `"__"`, `"_____"` all pass. `cqs slot create _` is legal. UI prints `*  _   chunks=0 …`. Cosmetic, untested. Pinning behavior with a test means future column-alignment / log-parsing changes don't silently break.
- **Suggested fix:** Add to `src/slot/mod.rs::tests`:
  - `test_validate_rejects_leading_dash` — `validate_slot_name("-foo")` should error (because of clap collision), or document and pin if intentionally accepted. Recommend rejecting: a name starting with `-` cannot be passed as a `--slot` argument value without `--slot=-foo`, which most users won't know.
  - `test_validate_rejects_trailing_dash` — pin behavior; recommend rejecting for consistency.
  - `test_validate_accepts_pure_underscore_or_underscore_only` — pin current behavior so a future "must contain alphanumeric" tightening surfaces as a test break, not silent UX change.

#### TC-ADV-1.30-7: `slot::migrate_legacy_index_to_default_slot` rollback path is untested
- **Difficulty:** medium
- **Location:** `src/slot/mod.rs:511-593` (migration), specifically the rollback loop at `:561-582`. No test for partial-failure rollback in `src/slot/mod.rs::tests` (`:850+` covers happy path + idempotency only).
- **Description:** The migration moves N files (typically `index.db` + `index.db-wal` + `index.db-shm` + 4 HNSW files + SPLADE = 8 candidates) from `.cqs/` to `.cqs/slots/default/`. If `move_file` fails on file K (cross-device, permission denied, EBUSY on Windows because `index.db-wal` is still open by a concurrent reader), the rollback loop at line 562-571 reverses the moves of files 1..K-1. Failure modes:
  - **Rollback itself fails**: line 564-569 logs but does not abort. After a failed rollback, files 1..K-1 are in `slots/default/`, file K is in the original `.cqs/` directory, and the active_slot pointer is **never written** (line 585 only runs on success). The next migration call hits the `slots_dir.exists()` check at line 523 and returns `Ok(false)` — *the project is now permanently broken* with files split across two locations. Untested.
  - **`fs::remove_dir(&dest)` at line 573 fails** because `slots/default/` contains rolled-back files that haven't been moved out. The cleanup is skipped silently (`let _ =`) so the next run sees `slots/` exist and does nothing. Same broken-project state. Untested.
  - **EBUSY on Windows for `index.db-wal`** specifically: this is the realistic trigger. A daemon running the watch loop holds the WAL open. A user runs `cqs index` from another shell, which does `Store::open` → triggers migration. WAL is locked. Migration step 2 (`-wal`) fails. Step 1 (`index.db`) succeeded — already moved into `slots/default/`. Rollback moves it back. Cleanup tries to `remove_dir(&dest)`. Now there's only one outcome: the project is fine. *Unless* the rollback `move_file(slots/default/index.db, .cqs/index.db)` itself fails (e.g., something raced to create a file at the target path) — then we have split state, no rollback, no test.
- **Suggested fix:** Add to `src/slot/mod.rs::tests`:
  - `test_migrate_rollback_on_second_file_failure` — plant `index.db` and `index.db-wal`, make `index.db-wal` fail to move (e.g., open it with an exclusive handle on Linux via `flock`, or remove read perms on the parent dir mid-test). Assert the rollback restores `index.db` to `.cqs/`, `slots/` is fully cleaned up, and the next migration call still works.
  - `test_migrate_rollback_failure_leaves_loud_signal` — make rollback itself fail (chmod the source dir read-only after the first move). Assert the migration returns `Err(SlotError::Migration(…))` AND the user-visible state contains a *single* known signal (not silent split state). Recommend writing a `.cqs/migration_failed` marker file the daemon checks at startup, or refuse to start when `slots/` and `.cqs/index.db` both exist.

#### TC-ADV-1.30-8: `LocalProvider` accepts non-HTTP `api_base` URLs and unbalanced `concurrency` configurations
- **Difficulty:** easy
- **Location:** `src/llm/local.rs:88-121` (`LocalProvider::new`), `:128-312` (`submit_via_chat_completions`), `:153` (channel sized at `concurrency.max(8) * 2`).
- **Description:** Three under-tested edge cases on the new (#1101) provider:
  - **Non-HTTP scheme**: `api_base = "file:///etc/passwd"` or `"gopher://example/"`. `Client::post(&url)` accepts the URL; `reqwest` errors at request time with a generic "URL scheme is not allowed" but the error is downgraded to `LlmError::Http` via `?` and retried 4× (each retry sleeping 500ms→4s). 7.5s wasted per item × N items, no specific signal that the user typo'd `file://` for `http://`. Untested.
  - **`api_base` with trailing slash**: `"http://x/v1/"` → `format!("{}/chat/completions", self.api_base)` → `"http://x/v1//chat/completions"`. Most servers handle the doubled slash; some (strict nginx configs, some llama.cpp builds) 404. The user's mock at `make_config(&format!("{}/v1", server.base_url()), …)` always uses no trailing slash. No regression test.
  - **`api_base` without `/v1`**: `"http://x"` → `"http://x/chat/completions"`. Some servers expect it (vLLM defaults to `/v1`); others reject. The doc at line 80-82 says "any server that speaks `/v1/chat/completions`" but the code does not enforce or normalize. No test pins the contract.
  - **`concurrency=64, items=1`**: line 153 sizes the bounded channel at `concurrency.max(8) * 2 = 128` for a single item. 64 worker threads spin up, 63 immediately exit on closed-channel after the single item is consumed. Wasteful but harmless — until you note it does this for *every* `submit_*` call inside the same daemon, and worker-thread allocation under heavy churn is non-trivial on glibc. No test verifies that small batches don't spawn full-concurrency thread pools.
- **Suggested fix:** Add to `src/llm/local.rs::tests`:
  - `test_non_http_api_base_fails_fast` — `make_config("file:///tmp/foo", …)`, `submit_batch_prebuilt(&items, …)`. Assert error returns within ~100ms (no 7.5s × N retry stall). Recommend adding a one-line URL scheme check in `LocalProvider::new`: bail if `Url::parse(&api_base).scheme() != "http" && != "https"`.
  - `test_api_base_with_trailing_slash_works` — pin behavior so the doubled-slash case either succeeds (current httpmock probably accepts) or normalizes.
  - `test_concurrency_clamped_to_item_count_when_smaller` — recommend `let workers = self.concurrency.min(items.len()).max(1);` at line 166. Test that for `items.len()=1`, only 1 worker thread is spawned (verify via thread name enumeration or a counter in a custom worker-spawn hook).

#### TC-ADV-1.30-9: Embedder `provider::ort_runtime_search_dir` and `find_ld_library_dir` — no test for malformed `/proc/self/cmdline` or pathological `LD_LIBRARY_PATH`
- **Difficulty:** easy
- **Location:** `src/embedder/provider.rs:67-83` (`ort_runtime_search_dir`), `:115-123` (`find_ld_library_dir`), `:34-62` (`ensure_ort_provider_libs`). No `#[test]` block in this file at all — provider detection paths are exercised only end-to-end via `Embedder::new`.
- **Description:** This file moved out of `embedder/mod.rs` in #1120 (Phase A) and gained the explicit symlink-and-search logic. It now reads `/proc/self/cmdline`, parses up to the first NUL, decodes UTF-8, and joins against CWD. Untested adversarial inputs:
  - **`/proc/self/cmdline` empty / single NUL**: `cmdline.iter().position(|&b| b == 0)` returns `None` (if no NUL) or `Some(0)` (if first byte is NUL). For `Some(0)`: `argv0 = ""`, `argv0.starts_with('/')` is false, falls through to `current_dir().ok()?.join("")` → `current_dir`. Then `abs_path.parent()` returns `Some(current_dir.parent())`. The symlink directory becomes the *parent* of CWD. If CWD is `/home/user/proj/.cqs`, ORT search dir becomes `/home/user/proj/`. Probably benign (no provider .so files there). But if CWD is `/`, parent is `None` and `ensure_ort_provider_libs` silently does nothing — provider activation fails on the first call, caller falls back to CPU with no diagnostic. Untested.
  - **Non-UTF8 argv[0]**: `std::str::from_utf8(&cmdline[..argv0_end]).ok()?` returns `None`. Function returns `None`. Same silent-CPU-fallback. Untested. While Linux argv[0] *should* be UTF-8, container runtimes occasionally inject non-UTF-8 bytes via `exec` for sandboxing / launchers.
  - **`LD_LIBRARY_PATH` with empty entries**: `"::/foo:"` — `ld_path.split(':')` produces `["", "", "/foo", ""]`. Filter `!p.is_empty()` rejects all empties. Then `Path::new("/foo").is_dir()` decides. Untested for the `LD_LIBRARY_PATH=":"` corner case.
  - **`LD_LIBRARY_PATH` containing the ORT cache itself** (`:ort_lib_dir`): the filter `!ort_cache_str.starts_with(p)` excludes *prefixes* of the ORT cache. If `ort_lib_dir = /home/u/.cache/ort.pyke.io/dfbin/x86_64-…/v1.x` and `LD_LIBRARY_PATH = /home/u/.cache`, the filter drops `/home/u/.cache` because `ort_cache_str` does start with it. Probably correct intent. But `LD_LIBRARY_PATH = /home/u/.cache/ort.pyke.io/dfbin/x86_64-unknown-linux-gnu/v0.x` (sibling cache version) is *not* a prefix of the active ORT lib dir, so it passes the filter and gets symlinks pointed *into* a stale ORT version's cache. Symlink overwrite of the user's other ORT install. Untested.
- **Suggested fix:** Create `src/embedder/provider.rs::tests` (currently empty):
  - `test_ort_runtime_search_dir_handles_empty_cmdline` — write a file at a temp path that begins with NUL; inject via test-only env var override or test-only feature flag. Assert returns `None` or a documented sentinel.
  - `test_find_ld_library_dir_skips_empty_entries` — `LD_LIBRARY_PATH=":/tmp:"`, assert it picks `/tmp` and not `""`.
  - `test_find_ld_library_dir_does_not_pick_sibling_ort_version_dir` — set up two cache dirs `…/v0.x` and `…/v1.x`, point `LD_LIBRARY_PATH` at `…/v0.x`, assert the symlink target is *not* `…/v0.x` (since that would corrupt the older cache). Recommend changing the prefix-check to a path-containment check via `Path::ancestors()`.

#### TC-ADV-1.30-10: `cache::EmbeddingCache::insert_many` — `blake3_hex_or_passthrough` accepts non-hex content_hash bytes that look like hex
- **Difficulty:** easy
- **Location:** `src/cache.rs:709-721` (`blake3_hex_or_passthrough`), `:674-703` (`insert_many`). No test for the passthrough/encode branch boundaries.
- **Description:** The function checks: if the bytes are valid UTF-8, length 64, and all ASCII hex digits → pass through; otherwise hex-encode each byte. Edge cases not tested:
  - **64-byte UTF-8 with NUL bytes that happen to be in ASCII hex range**: NUL (0x00) is not in `0..='9' | 'a'..='f' | 'A'..='F'`, so the all-hex check fails and we fall through to encode. Good. But not tested.
  - **64-byte UTF-8 that is all hex but uppercase**: `"ABCDEF…"` — `b.is_ascii_hexdigit()` accepts uppercase. So the cache stores uppercase hex for some entries and lowercase (the encoded path) for others. **Two writes for the same content_hash with different case produce two different cache rows.** PRIMARY KEY enforces uniqueness at the row level, but `(content_hash, model_fingerprint)` rows differ by case — so a chunk hashed once via passthrough (uppercase) and once via encode (lowercase) duplicates and both linger. Untested.
  - **Non-64-byte hex strings**: `"abc"` (3 bytes UTF-8, all hex). Length check fails (3 != 64). Falls into encode → `"616263"` is stored. Each call with the same input is deterministic, but a caller passing a 32-char hex string gets a 64-char hex of the *bytes of that hex string*, not its decoded value. Surprising. Untested.
  - The deeper issue: this function exists because `Chunk::content_hash` is *already* a hex string (lowercase, 64 chars), and the cache schema declares the column as `TEXT`. The passthrough is a fast path. Anything that doesn't fit the fast-path contract collides into a parallel hex encoding. Two different inputs can produce the same output: `b"\xab\xcd…"` (64 bytes) → hex-encode → 128 ascii chars; vs the literal string `"abcd…"` (64 chars, valid hex) → passthrough → 64 chars. These don't collide at length 64 vs 128, but they do at any matching length. *Specifically*: passing a 64-byte raw input where every byte happens to be `0x30` (ASCII '0') — that's UTF-8, hex-only, 64 chars long → passthrough as `"00…0"`. Vs another caller passing the literal 32-byte hex string `"00000000000000000000000000000000"` (32 chars, hex) → fails length check → encode → `"3030…30"` (64 chars). Different outputs. OK no collision. But an attacker controlling content_hash could craft inputs that hit the passthrough boundary intentionally; a more conservative fix is to *always* hex-encode and never trust passthrough.
- **Suggested fix:** Add to `src/cache.rs::tests`:
  - `test_blake3_hex_or_passthrough_uppercase_hex_passthrough` — pin: uppercase 64-char hex passes through unchanged. If we want lowercase invariant, normalize.
  - `test_blake3_hex_or_passthrough_short_hex_string_gets_encoded` — pin: 32-char hex string is encoded, not passed through (the surprising case).
  - `test_insert_many_does_not_dup_when_caller_alternates_passthrough_and_encode` — write the same logical chunk twice, once with hex string bytes and once with raw blake3 bytes; assert exactly one row in the DB after both inserts. This currently fails. Recommend: always normalize via blake3 hash of input bytes if length is not exactly 64 lowercase hex; or kill the passthrough fast path entirely (cost: one allocation per write — negligible).

## Summary

10 findings. Highest-impact gaps cluster around v1.30.0's *new* surfaces:
1. **#1101 LocalProvider** — unbounded body reads (TC-ADV-1.30-1, TC-ADV-1.30-8) DoS the daemon on a single hostile or panicked LLM endpoint. Trivial reqwest config fix.
2. **#1105 Slots+Cache** — TOCTOU in concurrent slot ops (TC-ADV-1.30-3), untested rollback in legacy migration (TC-ADV-1.30-7), and *cross-slot* cache poisoning via NaN/Inf passthrough (TC-ADV-1.30-2) which #1105 made worse by extending the cache lifetime.
3. **#1113 Background HNSW rebuild** — silent delta loss on thread panic (TC-ADV-1.30-4) is a real bug masked by an absent `catch_unwind`, plus dim-drift bail untested under concurrent slot promote.
4. **#1118 Serve auth** (TC-ADV-1.30-5) — case-sensitivity and percent-encoding gaps in `strip_token_param` cause real SEC-7 leakage (token survives in URL bar after redirect).
5. **#1120 Provider feature split** (TC-ADV-1.30-9) — silently falls back to CPU on malformed `/proc/self/cmdline` or pathological LD_LIBRARY_PATH; symlink target picking can corrupt sibling ORT cache versions.

Lowest-priority but cheap to add: slot-name validation edges (TC-ADV-1.30-6), cache passthrough/encode duality (TC-ADV-1.30-10).
