From 3963b28b502758cacc27a4848d4769c0e8b6f592 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 24 Apr 2026 06:21:11 -0500 Subject: [PATCH] =?UTF-8?q?aibridge:=20fix=20glob=5Fmatch=20=E2=80=94=20re?= =?UTF-8?q?move=20dead=20panic=20branch=20+=20add=20multi-*=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 9 scrum flagged routing.rs with OffByOne + NullableConfusion risks on the glob matcher. Two real bugs in one function: 1. The `else if parts.len() == 1` branch was dead AND panic-hazardous: split('*') on a string containing '*' always yields ≥2 parts, so the branch was unreachable — but if ever reached (via future caller or split-behavior change), `parts[1]` would index out of bounds and panic. 2. Multi-* patterns like `gpt-*-large*` fell through to exact-match because the `parts.len() == 2` branch only handled single-*. Result: a rule like `model_pattern: "gpt-*-oss-*"` would only match the literal string "gpt-*-oss-*", never an actual gpt-4-oss-120b. Fix walks parts left-to-right: prefix check, suffix check, each interior segment must appear in order. Cursor-advance logic ensures a mid-segment that appears before cursor (duplicate prefix) can't falsely match. 8 new tests cover: exact match, exact mismatch, leading/trailing/bare wildcards, multi-* in-order, multi-* wrong-order (regression guard), and the old panic-hazard case ("a*b*c" variants) as an explicit check. Workspace warnings unchanged at 11. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/aibridge/src/routing.rs | 44 +++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/crates/aibridge/src/routing.rs b/crates/aibridge/src/routing.rs index 7c1bb77..dbbce61 100644 --- a/crates/aibridge/src/routing.rs +++ b/crates/aibridge/src/routing.rs @@ -70,15 +70,19 @@ pub struct RouteDecision { } fn glob_match(pattern: &str, name: &str) -> bool { - if pattern.contains('*') { - let parts: Vec<&str> = pattern.split('*').collect(); - if parts.len() == 2 { - return name.starts_with(parts[0]) && name.ends_with(parts[1]); - } else if parts.len() == 1 { - return name.starts_with(parts[0]) || name.ends_with(parts[1]); - } - } - pattern == name + if !pattern.contains('*') { return pattern == name; } + let parts: Vec<&str> = pattern.split('*').collect(); + // Multi-* support: first must be prefix, last must be suffix, each + // interior piece must appear in order. Fixes the iter-9 finding + // where gpt-*-large* silently fell through to an exact-match path. + // Also removes the dead `parts.len() == 1` branch that accessed + // parts[1] and would panic if ever reached (unreachable today + // since split('*') on a string containing '*' always yields ≥2). + if !name.starts_with(parts[0]) || !name.ends_with(parts.last().unwrap()) { return false; } + let mut cursor = parts[0].len(); + parts[1..parts.len() - 1].iter().all(|mid| { + name[cursor..].find(mid).map(|pos| { cursor += pos + mid.len(); true }).unwrap_or(false) + }) } impl Default for RoutingRule { @@ -90,4 +94,26 @@ impl Default for RoutingRule { temperature: None, } } +} + +#[cfg(test)] +mod glob_match_tests { + use super::glob_match; + + #[test] fn exact_match() { assert!(glob_match("gpt-oss:120b", "gpt-oss:120b")); } + #[test] fn exact_mismatch() { assert!(!glob_match("a", "b")); } + #[test] fn leading_wildcard() { assert!(glob_match("*:120b", "gpt-oss:120b")); } + #[test] fn trailing_wildcard() { assert!(glob_match("gpt-oss:*", "gpt-oss:120b")); } + #[test] fn bare_wildcard() { assert!(glob_match("*", "anything")); } + #[test] fn multi_wildcard_in_order() { assert!(glob_match("gpt-*-oss-*", "gpt-4-oss-120b")); } + #[test] fn multi_wildcard_wrong_order() { assert!(!glob_match("b*a*", "abba")); } + #[test] fn multi_wildcard_panic_safety() { + // Regression: earlier impl had an unreachable `parts.len() == 1` + // branch that indexed parts[1] — would panic if ever hit. Now + // the split('*') invariant guarantees ≥2 parts when * present, + // and we handle all N-part cases explicitly. + assert!(glob_match("a*b*c", "abc")); + assert!(glob_match("a*b*c", "axxxbxxxc")); + assert!(!glob_match("a*b*c", "xxxbxxx")); + } } \ No newline at end of file