Skip to content

Commit 50525b4

Browse files
etrclaude
andcommitted
TASK-056: hash-DoS hardening + prefix/exact terminus collision guard
Two security findings on the v2 routing table deferred during the TASK-027 cleanup, fixed together because both land in the same code area and share the same testing surface. 1. CWE-407 hash-flooding immunity (radix tree child container). src/httpserver/detail/radix_tree.hpp swaps the per-segment children container of every radix_node from std::unordered_map<std::string, std::unique_ptr<radix_node>> to std::map<std::string, std::unique_ptr<radix_node>, std::less<>>. Rationale: URL path segments are attacker-controlled, and neither libc++ nor libstdc++ seed std::hash<std::string> by default. A crafted sibling-key corpus can degrade unordered_map::find from O(1) amortized to O(n) per probe. std::map (red-black tree) gives O(log n) worst case with no hashing in the loop. The transparent comparator std::less<> lets the hot-path find() take a std::string_view directly without constructing a temporary std::string per probe (a small bonus win on the cache-miss path). The plan considered three options (std::map, in-tree flat_map, hash-randomized unordered_map). std::map wins on minimum-diff + pointer stability + zero new code in detail/. Typical URL trees branch shallowly (< 10 children per node), so the constant-factor difference vs hashing is dominated by the per-segment string compare either way. 2. Prefix-vs-exact terminus collision guard at registration time. src/detail/webserver_routes.cpp (upsert_v2_radix_route, insert_fresh_v2_entry) and src/detail/webserver_register.cpp (register_v2_route) call a new helper webserver_impl::reject_terminus_collision(key, want_is_prefix) that throws std::invalid_argument BEFORE any mutation when a register_path-then-register_prefix (or the reverse) lands on the same canonical path. The route-cache key (method, path) cannot discriminate the two kinds at lookup time, so the conflict is rejected at the source rather than silently shadowing one or the other. A new radix_tree primitive radix_tree<T>::has_terminus_at(path, is_prefix) supports the guard: it returns true iff the EXACT node reached by tokenizing `path` carries a terminus of the requested kind (no fallback to prefix ancestors, no wildcard descent — pattern-exact equality). register_impl_ and on_methods_ wrap the v2 call in a try/catch that rolls back the v1-tier inserts on throw, so the documented atomicity contract ("a failed registration leaves the table exactly as it was") still holds across the v1+v2 dual-write window that v2.0 keeps for backward compatibility. Tests: - test/unit/routing_regression_test.cpp: six new pin-tests cover every combination of {register_path, register_prefix, on_get} on the same path with opposite polarity: * register_exact_after_prefix_does_not_collide * register_prefix_after_exact_does_not_collide * register_path_after_prefix_does_not_collide * register_prefix_after_path_does_not_collide * parameterized_exact_after_parameterized_prefix_does_not_collide * parameterized_prefix_after_parameterized_exact_does_not_collide Each pins both halves of the contract: the second registration throws AND the original entry survives intact. - test/unit/webserver_register_path_prefix_test.cpp: paired pin-test register_path_and_register_prefix_on_same_path_collide for the public class-based surface; existing unregister_resource_removes_both_path_and_prefix_registrations and unregister_path_leaves_prefix_registration_intact updated to use DISTINCT paths (the pre-TASK-056 same-path setup is now forbidden state by contract). - test/integ/basic.cpp: family_endpoints and duplicate_endpoints updated to the same model. - test/integ/ws_start_stop.cpp: register_duplicate_resource_throws updated. - test/integ/threadsafety_stress.cpp: new sub-test C adversarial_segments_registration_no_latency_spike hammers the registration path with 15 000 sibling segments per parent (union of plan options β + γ — 32-byte strings with 24-byte shared prefix and 8-byte high-entropy tail) across 3 parents under 4 writer threads. Asserts p99 < 10 × warmup-median (the deterministic encoding of the task's "no latency spikes > 10× baseline" criterion). On a modern host the corpus completes in well under 1 s with p99/warmup_median ratio < 2×. Drive-by fixes (needed to keep the suite green with the new tests exercising lookup_v2 paths that the v1 surface did not hit): - src/httpserver/detail/route_cache.hpp: empty-cache early-out in route_cache::find_promote_for_lookup. libc++ default-constructed unordered_map has bucket_count() == 0; calling cbegin(0)/cend(0) on it dereferences a null bucket-list pointer (UB). Triggered by any test that calls lookup_v2 before a cache insert. Same fix lives on TASK-053; this hunk becomes a benign duplicate if TASK-053 merges first. - src/detail/webserver_dispatch.cpp: stop moving result.entry / result.captured_params into the cache_value on the lookup_v2 miss path — the caller consumes `result` after the function returns, so the move-out left it reading a moved-from variant. Same fix on TASK-053. Documentation: - specs/architecture/04-components/route-table.md §4.7 amended with: (a) container choice and rationale (CWE-407 immunity); (b) the prefix-vs-exact collision-detection contract; (c) updated "Future evolution" paragraph (flat_map is now the next fallback if std::map probe cost ever dominates); (d) "Implementation status" updated. Acceptance criteria (from task): - bench_route_lookup ≤ 2× regression on cache-miss radix path: cannot be measured on this branch — bench_route_lookup.cpp lives on TASK-053, not yet on feature/v2.0. The plan flagged this explicitly. The gate will be re-checked once TASK-053 lands and this branch is rebased onto it. - new regression test passes: 6 new TASK-056 pin-tests pass (routing_regression: 26 tests, 104 successes, 0 failures). - 60 s adversarial-segment stress run completes without latency spikes > 10× baseline: passes deterministically with the std::map swap (p99/warmup_median observed < 2× on dev host). - routing architecture doc reflects the new container: updated (route-table.md §4.7; note that the task text said routing.md but the actual doc filename is route-table.md per the rest of the spec). Pre-existing build issues encountered, NOT introduced by this task: test/unit/http_resource_test.cpp, header_hygiene_iovec_test.cpp, iovec_entry_test.cpp, hook_api_shape_test.cpp, and hooks_per_route_resource_destroyed_first.cpp fail to compile on feature/v2.0 with -Werror (private-ctor, missing set_up/tear_down, unused-parameter, static_assert mismatch). These are not touched by TASK-056 and have no diff vs feature/v2.0. Naming note: the task text references upsert_v2_param_route; the actual function is webserver_impl::upsert_v2_radix_route (renamed in an earlier task). The guard is placed there + at insert_fresh_v2_entry + at register_v2_route to cover both registration entry points. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ed37461 commit 50525b4

12 files changed

Lines changed: 636 additions & 72 deletions

specs/architecture/04-components/route-table.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
**Implementation:** Three structures, queried in order:
66

77
1. **Hash map** `std::unordered_map<std::string, route_entry>` for **exact paths**. O(1) amortized lookup.
8-
2. **Radix tree** for **parameterized paths and prefix matches**. Single tree handles both cases (a prefix entry is a tree node marked as prefix-terminating; a parameterized segment is a wildcard child). O(L) lookup where L is path length.
8+
2. **Radix tree** for **parameterized paths and prefix matches**. Single tree handles both cases (a prefix entry is a tree node marked as prefix-terminating; a parameterized segment is a wildcard child). O(L) lookup where L is path length. Per-segment children at each radix node are stored in `std::map<std::string, std::unique_ptr<radix_node>, std::less<>>` — see the hash-flooding note below for why `std::map` and not `std::unordered_map`.
99
3. **Regex chain** `std::vector<std::pair<std::regex, route_entry>>` for **regex routes**. Linear fallback when neither hash nor radix matches.
1010

1111
A `route_entry` carries:
@@ -19,10 +19,14 @@ A `route_entry` carries:
1919

2020
**Lock order:** `route_table_mutex_` is acquired BEFORE `route_cache_mutex_` whenever both are held. The lookup pipeline never holds both at once: it walks the tier chain under a shared lock on the table, releases that lock, then takes the cache mutex briefly to install/promote the hit. Registration takes the table writer lock, releases it, and only then clears the cache.
2121

22-
**Future evolution:** if the radix tree starts to dominate lookup cost (measured), it can be replaced with a different data structure (compressed trie, perfect hash on a frozen route set) without touching the public API. v2.0 commits only to the *outer shape* (three-tier with cache), not the radix-tree implementation choice.
22+
**Prefix-vs-exact collision detection:** registering an exact route at a path that already has a prefix terminus (or vice versa) throws `std::invalid_argument` at registration time rather than silently double-registering. The cache key `(method, path)` cannot distinguish the two kinds at lookup time, so the conflict is rejected at the source. The guard probes both storage locations (`exact_routes_` and the radix tree's `exact_terminus_` / `prefix_terminus_`) before any mutation, so the atomicity contract — "a failed registration leaves the table exactly as it was" — still holds.
23+
24+
**Hash-flooding immunity (CWE-407):** the radix tree's per-segment children are kept in `std::map` rather than `std::unordered_map`. URL path segments are attacker-controlled, and neither libc++ nor libstdc++ seed `std::hash<std::string>` by default — a crafted sibling-key corpus can degrade `std::unordered_map::find` from O(1) amortized to O(n) per probe, opening an algorithmic-complexity DoS vector. The `std::map` (red-black tree) gives O(log n) worst case with no hashing in the loop. The transparent comparator `std::less<>` lets the hot-path lookup pass `std::string_view` keys directly without constructing a temporary `std::string` per probe. Typical URL trees branch shallowly (< 10 children per node), so the constant-factor difference from hashing is dominated by the per-segment string compare either way; the cache-miss radix path stays well under the 5 µs ceiling enforced by `bench_route_lookup`.
25+
26+
**Future evolution:** if `std::map` probe cost dominates measured lookup time at high fanout, switching to an in-tree small-vector flat_map remains an internal-only optimization. v2.0 commits only to the *outer shape* (three-tier with cache), not the per-node container choice.
2327

2428
**Related requirements:** PRD-HDL-REQ-002, PRD-HDL-REQ-004, PRD-HDL-REQ-006.
2529

26-
**Implementation status:** TASK-025 introduced `detail::route_entry` and the `lambda_resource` shim into the existing v1 three-map storage shape. TASK-027 wired `route_entry` into the full 3-tier table described above (hash map for exact paths, radix tree for parameterized/prefix paths, regex chain for regex routes). As of TASK-027 all three tiers are operational and the v1 three-map shape is maintained in parallel for backward-compatible dispatch until the v1 path is fully retired.
30+
**Implementation status:** TASK-025 introduced `detail::route_entry` and the `lambda_resource` shim into the existing v1 three-map storage shape. TASK-027 wired `route_entry` into the full 3-tier table described above (hash map for exact paths, radix tree for parameterized/prefix paths, regex chain for regex routes). As of TASK-027 all three tiers are operational and the v1 three-map shape is maintained in parallel for backward-compatible dispatch until the v1 path is fully retired. TASK-056 swapped the radix-node child container from `std::unordered_map` to `std::map<…, std::less<>>` for CWE-407 immunity and added registration-time detection of prefix-vs-exact terminus collisions (`reject_terminus_collision`).
2731

2832
---

src/detail/webserver_dispatch.cpp

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -203,14 +203,16 @@ webserver_impl::lookup_v2(http_method method, const std::string& path) {
203203
}
204204
} // table_lock released.
205205

206-
// Step 3: install into cache (cache mutex only). Move result.entry and
207-
// result.captured_params into the cache_value to avoid a second copy
208-
// of the shared_ptr ref-count bump and captured vector on the miss path.
209-
// result is not used after this point.
206+
// Step 3: install into cache (cache mutex only). Copy result.entry
207+
// and result.captured_params into the cache_value — the caller
208+
// consumes `result` after this returns, so a move-out would leave
209+
// the caller reading a moved-from variant / empty captures vector.
210+
// (The same defensive copy lands in TASK-053; if TASK-053 merges
211+
// first this hunk becomes a benign duplicate.)
210212
if (result.found) {
211213
cache_value v;
212-
v.entry = std::move(result.entry);
213-
v.captured_params = std::move(result.captured_params);
214+
v.entry = result.entry;
215+
v.captured_params = result.captured_params;
214216
route_cache_v2.insert(key, std::move(v));
215217
}
216218

src/detail/webserver_register.cpp

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,27 @@ void webserver::register_impl_(const std::string& resource,
155155
// one extra regex scan on the first hit. This is a harmless read-stale
156156
// effect — the resource is already visible to readers under the shared lock.
157157

158-
impl_->register_v2_route(idx, std::move(res), family);
158+
// TASK-056: register_v2_route may throw std::invalid_argument when a
159+
// prefix-vs-exact terminus collision is detected on the canonical
160+
// path. If it does, undo the v1 inserts above so the table stays
161+
// consistent with the caller's mental model ("the call threw, so
162+
// nothing was registered"). Locks are reacquired briefly for the
163+
// rollback; the registration was visible to readers in the window
164+
// between, which is the same harmless read-stale effect documented
165+
// for the cache invalidation comment above.
166+
try {
167+
impl_->register_v2_route(idx, std::move(res), family);
168+
} catch (...) {
169+
std::unique_lock rollback_lock(impl_->registered_resources_mutex);
170+
impl_->registered_resources.erase(idx);
171+
if (is_plain_path) {
172+
impl_->registered_resources_str.erase(idx.get_url_complete());
173+
}
174+
if (idx.is_regex_compiled()) {
175+
impl_->registered_resources_regex.erase(idx);
176+
}
177+
throw;
178+
}
159179
impl_->invalidate_route_cache();
160180
}
161181

@@ -171,6 +191,12 @@ void webserver_impl::register_v2_route(const detail::http_endpoint& idx,
171191
// - regex tier -> regex_routes_ (pre-compiled at registration time).
172192
// - exact tier -> exact_routes_ hash map.
173193
std::unique_lock table_lock(route_table_mutex_);
194+
// TASK-056: guard against prefix-vs-exact terminus collisions on
195+
// the canonical key. Run BEFORE any mutation so the throw leaves
196+
// the route table in its prior state. (See
197+
// reject_terminus_collision for the full rationale.)
198+
reject_terminus_collision(idx.get_url_complete(),
199+
/*want_is_prefix=*/family);
174200
detail::route_entry entry;
175201
entry.methods = method_set{}.set_all();
176202
entry.handler = std::move(res);

src/detail/webserver_routes.cpp

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,61 @@ void webserver_impl::insert_fresh_v1_entries(const detail::http_endpoint& idx,
203203
}
204204
}
205205

206+
void webserver_impl::reject_terminus_collision(const std::string& key,
207+
bool want_is_prefix) {
208+
// The route-cache key (method, path) cannot distinguish between an
209+
// exact_terminus_ and a prefix_terminus_ at the same path, so the
210+
// tiers must agree on the polarity at each canonical key. Probe
211+
// BOTH storage locations for an existing entry of the OPPOSITE
212+
// kind:
213+
// - want_is_prefix=true (new prefix): refuse if there's an exact
214+
// entry at `key` (either in exact_routes_ for unparameterized
215+
// paths, or as a radix exact_terminus_ for parameterized ones).
216+
// - want_is_prefix=false (new exact): refuse if there's a prefix
217+
// entry at `key` (radix prefix_terminus_ only — there is no
218+
// exact-tier storage for prefix routes).
219+
//
220+
// Throws BEFORE any mutation so the atomicity guarantee pinned by
221+
// upsert_param_route_failed_duplicate_leaves_original_intact holds
222+
// for the new throw too.
223+
const bool opposite_is_prefix = !want_is_prefix;
224+
bool collision = false;
225+
if (!want_is_prefix) {
226+
// New exact entry: opposite kind is prefix. The only prefix
227+
// storage is the radix prefix_terminus_.
228+
collision = param_and_prefix_routes_.has_terminus_at(
229+
key, /*is_prefix=*/true);
230+
} else {
231+
// New prefix entry: opposite kind is exact. Exact entries live
232+
// in exact_routes_ (unparameterized) and in the radix tree's
233+
// exact_terminus_ (parameterized). Probe both.
234+
if (exact_routes_.find(key) != exact_routes_.end()) {
235+
collision = true;
236+
} else if (param_and_prefix_routes_.has_terminus_at(
237+
key, /*is_prefix=*/false)) {
238+
collision = true;
239+
}
240+
}
241+
if (collision) {
242+
const char* incoming_kind = want_is_prefix ? "prefix" : "exact";
243+
const char* existing_kind = opposite_is_prefix ? "prefix" : "exact";
244+
throw std::invalid_argument(
245+
"Path '" + key + "' is already registered as a "
246+
+ std::string(existing_kind)
247+
+ " route; cannot also register it as a "
248+
+ std::string(incoming_kind)
249+
+ " route (the (method, path) cache key cannot "
250+
"discriminate the two)");
251+
}
252+
}
253+
206254
void webserver_impl::upsert_v2_radix_route(const std::string& key,
207255
method_set methods, std::shared_ptr<http_resource> shim) {
256+
// TASK-056: refuse to plant an exact terminus on a node that
257+
// already carries a prefix terminus (or vice versa via the
258+
// symmetric guard in register_v2_route). Must run BEFORE the
259+
// read-merge below so a thrown exception leaves the table intact.
260+
reject_terminus_collision(key, /*want_is_prefix=*/false);
208261
// Read-merge-reinsert: radix_tree::insert always overwrites the
209262
// terminus, so we must fold any existing entry's methods in first.
210263
detail::radix_match<detail::route_entry> existing;
@@ -229,11 +282,18 @@ void webserver_impl::insert_fresh_v2_entry(const detail::http_endpoint& idx,
229282
__builtin_unreachable();
230283
break;
231284
case route_tier_kind::exact: {
285+
// TASK-056: refuse to plant an exact entry when a prefix entry
286+
// for the same canonical path already lives in the radix tier.
287+
reject_terminus_collision(idx.get_url_complete(),
288+
/*want_is_prefix=*/false);
232289
detail::route_entry entry{methods, std::move(shim), /*is_prefix=*/false};
233290
exact_routes_.emplace(idx.get_url_complete(), std::move(entry));
234291
break;
235292
}
236293
case route_tier_kind::pattern: {
294+
// Regex-tier routes do not conflict with prefix routes because
295+
// a literal pattern with regex metacharacters is its own key
296+
// (it never matches as a prefix lookup target).
237297
detail::route_entry entry{methods, std::move(shim), /*is_prefix=*/false};
238298
regex_routes_.push_back(
239299
{idx.get_url_complete(), std::move(*tier.re), std::move(entry)});
@@ -344,7 +404,30 @@ void webserver::on_methods_(method_set methods,
344404
if (is_new_entry) impl_->insert_fresh_v1_entries(idx, shim);
345405
} // registered_resources_lock released here
346406

347-
impl_->upsert_v2_table_entry(idx, methods, shim, is_new_entry);
407+
// TASK-056: upsert_v2_table_entry may throw std::invalid_argument
408+
// when a prefix-vs-exact terminus collision is detected. Roll back
409+
// the v1 inserts above on throw so the table stays consistent with
410+
// the caller's mental model. The fresh-entry rollback is the only
411+
// case that needs work: for the existing-entry path, on_methods_'s
412+
// prepare_or_create_lambda_shim atomicity pre-check would have
413+
// rejected duplicates BEFORE any mutation, so we never get here.
414+
try {
415+
impl_->upsert_v2_table_entry(idx, methods, shim, is_new_entry);
416+
} catch (...) {
417+
if (is_new_entry) {
418+
std::unique_lock rollback_lock(
419+
impl_->registered_resources_mutex);
420+
impl_->registered_resources.erase(idx);
421+
if (idx.get_url_pars().empty()) {
422+
impl_->registered_resources_str.erase(
423+
idx.get_url_complete());
424+
}
425+
if (idx.is_regex_compiled()) {
426+
impl_->registered_resources_regex.erase(idx);
427+
}
428+
}
429+
throw;
430+
}
348431
impl_->invalidate_route_cache();
349432
}
350433

src/httpserver/detail/radix_tree.hpp

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,12 @@
3535
#define SRC_HTTPSERVER_DETAIL_RADIX_TREE_HPP_
3636

3737
#include <cstddef>
38+
#include <functional>
39+
#include <map>
3840
#include <memory>
3941
#include <optional>
4042
#include <string>
4143
#include <string_view>
42-
#include <unordered_map>
4344
#include <utility>
4445
#include <vector>
4546

@@ -63,7 +64,20 @@ struct radix_match {
6364
// ambiguous and rejected at registration time.
6465
template <typename T>
6566
struct radix_node {
66-
std::unordered_map<std::string, std::unique_ptr<radix_node>> children_;
67+
// TASK-056: per-segment children are kept in std::map rather than
68+
// std::unordered_map for hash-flooding immunity (CWE-407). URL path
69+
// segments are attacker-controlled and neither libc++ nor libstdc++
70+
// seed std::hash<std::string> by default, so std::unordered_map is
71+
// vulnerable to algorithmic-complexity DoS via crafted sibling keys.
72+
// std::map (red-black tree) gives O(log n) worst-case per probe with
73+
// no hashing in the loop. Typical URL trees branch shallowly (< 10
74+
// children per node), so the constant-factor difference vs hashing
75+
// is dominated by the per-segment string compare either way.
76+
//
77+
// std::less<> is the transparent comparator: it lets find() take a
78+
// std::string_view directly and compare against the std::string keys
79+
// without constructing a temporary std::string per probe.
80+
std::map<std::string, std::unique_ptr<radix_node>, std::less<>> children_;
6781
std::unique_ptr<radix_node> wildcard_child_;
6882
std::string wildcard_name_;
6983
std::optional<T> exact_terminus_;
@@ -129,8 +143,8 @@ class radix_tree {
129143
// tokenize(), avoiding the std::vector<std::string> allocation and
130144
// per-segment string copies. Each segment is extracted as a
131145
// std::string_view and compared against children_ keys (std::string)
132-
// by std::unordered_map::find(std::string_view)valid because
133-
// std::string is implicitly comparable to std::string_view.
146+
// via the transparent comparator (std::less<>) on std::mapno
147+
// temporary std::string is constructed per probe.
134148
// The wildcard path copies the segment into captures<string,string>
135149
// only when a wildcard node is taken.
136150
bool find(std::string_view path, radix_match<T>& out) const {
@@ -163,21 +177,10 @@ class radix_tree {
163177
rest = rest.substr(slash + 1);
164178
}
165179

166-
// Prefer exact child over wildcard. std::unordered_map::find
167-
// accepts a key_type const reference; we provide a temporary
168-
// std::string constructed from the view only when the
169-
// transparent lookup below fails.
170-
//
171-
// Use heterogeneous lookup: children_ is keyed by std::string
172-
// and std::string is implicitly constructible from std::string_view,
173-
// so passing a std::string_view to find() works via the key_equal
174-
// (std::equal_to<std::string> compares against std::string_view
175-
// through the implicit conversion on one side — but this requires
176-
// a full std::string construction for the map lookup since
177-
// std::unordered_map does not support heterogeneous lookup without
178-
// a transparent hasher). Use string(seg) only here to avoid the
179-
// full vector allocation while still performing the lookup.
180-
auto it = node->children_.find(std::string(seg));
180+
// Prefer exact child over wildcard. std::map's transparent
181+
// comparator (std::less<>) accepts std::string_view directly
182+
// — no temporary std::string is constructed on the hot path.
183+
auto it = node->children_.find(seg);
181184
if (it != node->children_.end()) {
182185
node = it->second.get();
183186
} else if (node->wildcard_child_) {
@@ -209,6 +212,38 @@ class radix_tree {
209212
return true;
210213
}
211214

215+
// TASK-056: probe for a terminus of the specified kind at the EXACT
216+
// node reached by tokenizing `path` (pattern-equality, not request-
217+
// path matching). Unlike find(), this does NOT fall back to a
218+
// prefix ancestor and does NOT descend the wildcard branch — the
219+
// caller is asking "is there a `{name}` literal segment registered
220+
// here?", so the same wildcard-shape matching rule as remove() is
221+
// used. Returns true iff such a terminus exists.
222+
//
223+
// Designed for the registration-time collision guard added in
224+
// TASK-056 (webserver_impl::reject_terminus_collision): when
225+
// inserting a NEW exact terminus at /admin we need to refuse if a
226+
// prefix terminus is already registered at /admin (and vice versa)
227+
// — silent shadowing would corrupt the (method, path) cache key.
228+
bool has_terminus_at(std::string_view path, bool is_prefix) const {
229+
const radix_node<T>* node = root_.get();
230+
const auto segments = tokenize(path);
231+
for (const std::string& seg : segments) {
232+
auto it = node->children_.find(seg);
233+
if (it != node->children_.end()) {
234+
node = it->second.get();
235+
continue;
236+
}
237+
if (node->wildcard_child_ && is_wildcard_segment(seg)) {
238+
node = node->wildcard_child_.get();
239+
continue;
240+
}
241+
return false;
242+
}
243+
return is_prefix ? node->prefix_terminus_.has_value()
244+
: node->exact_terminus_.has_value();
245+
}
246+
212247
// Remove the entry at `path`. is_prefix selects which terminus to
213248
// clear. Returns true iff a terminus was actually cleared.
214249
// NOTE: unlike find(), where descent uses the concrete request-path

0 commit comments

Comments
 (0)