返回 CodeWhale
resolver.rs
根目录 / crates / config / src / route / resolver.rs
1 //! The sole producer of [`ReadyRouteCandidate`] (#3384).
2 //!
3 //! [`RouteResolver::resolve`] is the ONLY caller of
4 //! `ReadyRouteCandidate::new`. It resolves a [`RouteRequest`] into an
5 //! executable route using:
6 //!
7 //! 1. provider from `explicit_provider` ONLY (no base-URL / prefix sniffing);
8 //! when absent, the workspace default provider scope is used. The provider
9 //! is NEVER inferred from a model prefix.
10 //! 2. the model selector, interpreted STRICTLY within that provider's scope
11 //! against resolver-provided offerings plus the provider default. The default
12 //! resolver uses [`bundled_offerings`], while tests or snapshot loaders can
13 //! inject Models.dev-derived rows. Prefixed selectors are preserved verbatim
14 //! as the [`WireModelId`].
15 //! 3. `auto` => the [`LogicalModelRef::is_auto`] sentinel, never a literal
16 //! model.
17 //!
18 //! It encodes its OWN minimal direct/aggregator/local classification because
19 //! the tui helpers (`provider_passes_model_through` /
20 //! `accepts_custom_model_ids`) are not reachable from `crates/config`. The
21 //! classification here is deliberately NARROWER than tui's `validate_route`:
22 //! it only rejects [`RouteError::ForeignModelForDirectProvider`] for a small
23 //! set of strict direct providers given a clearly-foreign selector;
24 //! aggregators, local, and custom endpoints pass through `Ok` with
25 //! `validation.ok == true`.
26 //!
27 //! There is deliberately no prompt-text / freeform field on [`RouteRequest`],
28 //! which structurally bars prompt-content routing.
29
30 use super::candidate::{
31 LimitField, PricingSku, ReadyRouteCandidate, ResolvedAuthSource, ResolvedEndpoint,
32 SourcedLimitOverride, ValidationReport,
33 };
34 use super::capabilities::RouteCapabilities;
35 use super::descriptor::ProviderDescriptor;
36 use super::errors::RouteError;
37 use super::ids::{LogicalModelRef, ModelId, ProviderId, WireModelId};
38 use super::offering::{ProviderModelOffering, RouteLimits, bundled_offerings};
39 use crate::catalog::{CatalogOffering, bundled_catalog_offerings};
40 use crate::provider::WirePolicy;
41 use crate::{ProviderKind, opencode_go_chat_model_id, provider_preserves_custom_base_url_model};
42
43 /// A request to resolve into an executable route.
44 ///
45 /// Note the absence of any prompt-text/freeform field: the resolver cannot see
46 /// prompt content, so it cannot silently route on it.
47 #[derive(Debug, Clone, Default)]
48 pub struct RouteRequest {
49 /// Explicit provider choice. The ONLY source of provider identity.
50 pub explicit_provider: Option<ProviderKind>,
51 /// The model the caller selected (may be `auto` or prefixed).
52 pub model_selector: Option<LogicalModelRef>,
53 /// A previously-saved provider wire model id, used as scope fallback.
54 pub saved_provider_model: Option<WireModelId>,
55 /// An explicit base URL override for the endpoint.
56 pub base_url_override: Option<String>,
57 /// Sourced limit overrides, applied in order BEFORE the candidate is
58 /// constructed and recorded on it as provenance. This is the ONLY channel
59 /// for adjusting a route's effective limits: the candidate itself is
60 /// immutable once minted.
61 pub limit_overrides: Vec<SourcedLimitOverride>,
62 }
63
64 /// Resolves [`RouteRequest`]s into [`ReadyRouteCandidate`]s.
65 #[derive(Debug, Clone)]
66 pub struct RouteResolver {
67 offerings: Vec<ProviderModelOffering>,
68 }
69
70 /// Offering-owned facts selected within one provider scope before the final
71 /// executable route candidate is minted.
72 struct ResolvedOffering {
73 wire_model_id: WireModelId,
74 canonical_model: Option<ModelId>,
75 endpoint_key: String,
76 limits: RouteLimits,
77 capabilities: RouteCapabilities,
78 pricing: PricingSku,
79 }
80
81 impl ResolvedOffering {
82 fn unknown(wire_model_id: WireModelId) -> Self {
83 Self {
84 wire_model_id,
85 canonical_model: None,
86 endpoint_key: "chat".to_string(),
87 limits: RouteLimits::default(),
88 capabilities: RouteCapabilities::default(),
89 pricing: PricingSku::UnknownOrStale,
90 }
91 }
92
93 fn from_offering(offering: &ProviderModelOffering) -> Self {
94 Self {
95 wire_model_id: offering.wire_model_id.clone(),
96 canonical_model: offering.canonical_model.clone(),
97 endpoint_key: offering.endpoint_key.clone(),
98 limits: offering.limits,
99 capabilities: offering.capabilities,
100 pricing: offering.pricing.clone(),
101 }
102 }
103 }
104
105 impl Default for RouteResolver {
106 fn default() -> Self {
107 Self::new()
108 }
109 }
110
111 impl RouteResolver {
112 /// Construct a resolver with CodeWhale's bundled offline offerings.
113 ///
114 /// The default offerings are the committed Models.dev-shaped catalog asset
115 /// (`crate::catalog::bundled_catalog_offerings`, real context windows and
116 /// honest per-row `cost`) merged with the tiny hand seam
117 /// ([`bundled_offerings`]). The hand seam is kept and given precedence on a
118 /// `(provider, wire id)` collision: it encodes the curated canonical-model
119 /// joins the route invariants depend on (e.g. a DeepSeek-native row and the
120 /// aggregator rows that map a prefixed wire id back to `deepseek-v4-pro`),
121 /// which generated Models.dev JSON does not prove. Asset-only rows (GLM,
122 /// Kimi, MiniMax, Qwen, …) add the real provider/model facts the picker and
123 /// candidates were previously missing.
124 #[must_use]
125 pub fn new() -> Self {
126 Self::from_offerings(default_offerings())
127 }
128
129 /// Construct a resolver from a provider-scoped offering catalog.
130 ///
131 /// This is the bridge for Models.dev snapshots: callers parse a catalog,
132 /// emit provider offerings, then hand those rows to the resolver without
133 /// changing route-resolution semantics.
134 #[must_use]
135 pub fn from_offerings(offerings: Vec<ProviderModelOffering>) -> Self {
136 Self { offerings }
137 }
138
139 /// Resolve a request into an executable route candidate.
140 ///
141 /// # Errors
142 /// Returns [`RouteError`] when the model is empty, the provider is invalid,
143 /// or a clearly-foreign model is requested for a strict direct provider.
144 pub fn resolve(&self, req: &RouteRequest) -> Result<ReadyRouteCandidate, RouteError> {
145 // 1. Provider scope from explicit choice only; default otherwise.
146 // The provider is NEVER inferred from a model prefix.
147 let provider_kind = req.explicit_provider.unwrap_or_default();
148 let descriptor = ProviderDescriptor::for_kind(provider_kind);
149 let provider_id = descriptor.id();
150 let default_offering = self.default_offering(&provider_id);
151
152 // 2. Determine the logical selector from explicit choice, then the
153 // saved-model fallback, then the provider default.
154 let logical_model = match &req.model_selector {
155 Some(selector) => selector.clone(),
156 None => {
157 // No selector: fall back to saved wire model, then provider
158 // default. Both stay in the resolved provider's scope.
159 let raw = req
160 .saved_provider_model
161 .as_ref()
162 .map(|w| w.as_str().to_string())
163 .unwrap_or_else(|| {
164 default_offering.map_or_else(
165 || descriptor.default_wire_model().as_str().to_string(),
166 |offering| offering.wire_model_id.as_str().to_string(),
167 )
168 });
169 LogicalModelRef::from(raw)
170 }
171 };
172
173 // Reject an empty selector from ANY source (explicit, saved, or a
174 // degenerate default), not just an empty explicit selector.
175 if logical_model.raw().is_empty() {
176 return Err(RouteError::EmptyModel);
177 }
178
179 // 3. `auto` is an opt-in sentinel: resolve to the provider default wire
180 // id without treating "auto" as a literal model name.
181 let is_auto = logical_model.is_auto();
182
183 // 4. Map the selector to a wire id within provider scope.
184 // Prefixed selectors are preserved VERBATIM as the wire id.
185 let custom_endpoint =
186 request_uses_custom_endpoint(&descriptor, req.base_url_override.as_deref());
187 let class = if custom_endpoint {
188 ProviderClass::LocalOrCustom
189 } else {
190 classify(provider_kind)
191 };
192 let model_aware = descriptor.wire_policy() == WirePolicy::ModelAware;
193 // A model-aware protocol row is an exact provider-endpoint fact.
194 // OpenCode Zen's published roster is closed; DeepSeek's direct route
195 // deliberately preserves its existing future-model pass-through and
196 // sends unknown bare ids over Chat until an exact Responses row exists.
197 // A custom DeepSeek-compatible endpoint retains that pass-through, but
198 // a custom URL must not weaken another model-aware provider's closed
199 // protocol roster.
200 let require_catalog_match = model_aware && provider_kind != ProviderKind::Deepseek;
201 let mut selected = if is_auto {
202 match default_offering {
203 None if require_catalog_match => {
204 return Err(RouteError::UnsupportedModelProtocol {
205 provider: provider_id.clone(),
206 model: descriptor.default_wire_model().as_str().to_string(),
207 endpoint_key: "unproven".to_string(),
208 });
209 }
210 None => ResolvedOffering::unknown(descriptor.default_wire_model()),
211 Some(offering) => ResolvedOffering::from_offering(offering),
212 }
213 } else {
214 self.scope_selector(
215 provider_kind,
216 &provider_id,
217 &logical_model,
218 class,
219 require_catalog_match,
220 )?
221 };
222 if provider_kind == ProviderKind::Deepseek {
223 if custom_endpoint {
224 selected.endpoint_key = "chat".to_string();
225 } else if selected.canonical_model.is_none()
226 && deepseek_versioned_model_prefers_responses(selected.wire_model_id.as_str())
227 {
228 // DeepSeek introduced its native agent wire on V4 Flash. Keep
229 // exact catalog rows authoritative (notably V4 Pro => Chat),
230 // while allowing future versioned direct models to adopt the
231 // new Responses surface without a Codewhale release.
232 selected.endpoint_key = "responses".to_string();
233 }
234 }
235 if custom_endpoint {
236 // A documented first-party server tool is an endpoint-owned fact.
237 // Reusing a provider enum/model id against a custom compatible
238 // endpoint cannot carry that fact across the authority boundary.
239 selected.capabilities.server_side_web_search =
240 super::capabilities::CapabilityState::Unknown;
241 }
242
243 let protocol = descriptor
244 .protocol_for_endpoint(&selected.endpoint_key)
245 .ok_or_else(|| RouteError::UnsupportedModelProtocol {
246 provider: provider_id.clone(),
247 model: selected.wire_model_id.as_str().to_string(),
248 endpoint_key: selected.endpoint_key.clone(),
249 })?;
250 let endpoint = ResolvedEndpoint {
251 base_url: req
252 .base_url_override
253 .clone()
254 .unwrap_or_else(|| descriptor.default_base_url().to_string()),
255 endpoint_key: selected.endpoint_key,
256 protocol,
257 };
258
259 // Advisory validation (#1519): a non-loopback `http://` endpoint sends
260 // credentials in plaintext. This is advisory, not a hard fail, so
261 // `ok` stays true and local `http://localhost` runtimes (Ollama / vLLM /
262 // SGLang defaults) stay clean.
263 let mut messages = Vec::new();
264 if endpoint_uses_insecure_http(&endpoint.base_url) {
265 messages
266 .push("endpoint uses insecure http:// (credentials sent in plaintext)".to_string());
267 }
268 let validation = ValidationReport { ok: true, messages };
269
270 // Apply caller-requested limit overrides in order, BEFORE the candidate
271 // is minted. The candidate is immutable afterwards; the applied
272 // overrides are recorded on it as provenance.
273 let mut limits = selected.limits;
274 for limit_override in &req.limit_overrides {
275 match limit_override.field {
276 LimitField::ContextTokens => limits.context_tokens = limit_override.value,
277 LimitField::InputTokens => limits.input_tokens = limit_override.value,
278 LimitField::OutputTokens => limits.output_tokens = limit_override.value,
279 }
280 }
281
282 Ok(ReadyRouteCandidate::new(
283 provider_id,
284 provider_kind,
285 logical_model,
286 selected.canonical_model,
287 selected.wire_model_id,
288 endpoint,
289 // The resolver never inspects credentials: auth is honestly
290 // `Unresolved` at resolution time, not a claimed `Missing`.
291 ResolvedAuthSource::Unresolved,
292 protocol,
293 limits,
294 selected.capabilities,
295 // #3085: honest pricing projected from the matched offering (the
296 // catalog layer maps sourced cost → SKU); `UnknownOrStale` whenever
297 // no offering was matched or the offering carried no price.
298 Some(selected.pricing),
299 validation,
300 req.limit_overrides.clone(),
301 ))
302 }
303
304 /// Interpret a concrete (non-auto) selector strictly within provider scope.
305 fn scope_selector(
306 &self,
307 provider_kind: ProviderKind,
308 provider_id: &ProviderId,
309 logical_model: &LogicalModelRef,
310 class: ProviderClass,
311 require_catalog_match: bool,
312 ) -> Result<ResolvedOffering, RouteError> {
313 // OpenCode Go publishes one combined model roster across two wire
314 // protocols. Codewhale's provider is deliberately Chat Completions
315 // only, so this allowlist must sit at the sole route-candidate seam.
316 // In particular, a custom base URL must not reopen generic
317 // LocalOrCustom pass-through for Messages-only model ids.
318 let raw = if provider_kind == ProviderKind::OpencodeGo {
319 opencode_go_chat_model_id(logical_model.raw()).ok_or_else(|| {
320 RouteError::ForeignModelForDirectProvider {
321 provider: provider_id.clone(),
322 model: logical_model.raw().to_string(),
323 }
324 })?
325 } else if provider_kind == ProviderKind::OpencodeZen {
326 logical_model
327 .raw()
328 .strip_prefix("opencode/")
329 .or_else(|| logical_model.raw().strip_prefix("opencode-zen/"))
330 .unwrap_or_else(|| logical_model.raw())
331 } else {
332 provider_scoped_wire_alias(provider_kind, logical_model.raw(), class)
333 };
334
335 // Try to match a catalog offering owned by THIS provider, either by
336 // canonical model id or by exact wire id. This keeps interpretation
337 // inside provider scope; offerings from other providers are ignored.
338 for offering in &self.offerings {
339 if offering.provider != *provider_id {
340 continue;
341 }
342 let matches_canonical = offering
343 .canonical_model
344 .as_ref()
345 .is_some_and(|m| m.as_str() == raw);
346 let matches_wire = offering.wire_model_id.as_str() == raw;
347 if matches_canonical || matches_wire {
348 return Ok(ResolvedOffering::from_offering(offering));
349 }
350 }
351
352 // No catalog match. Apply class-specific pass-through rules.
353 match class {
354 ProviderClass::StrictDirect => {
355 if self.selector_matches_other_provider_offering(provider_id, raw) {
356 return Err(RouteError::ForeignModelForDirectProvider {
357 provider: provider_id.clone(),
358 model: raw.to_string(),
359 });
360 }
361 // A clearly-foreign selector for a strict direct provider is
362 // rejected. "Clearly foreign" = it carries an aggregator/org
363 // namespace prefix, which a direct provider never expects.
364 if logical_model.namespace_hint().is_some() {
365 return Err(RouteError::ForeignModelForDirectProvider {
366 provider: provider_id.clone(),
367 model: raw.to_string(),
368 });
369 }
370 if require_catalog_match {
371 return Err(RouteError::UnsupportedModelProtocol {
372 provider: provider_id.clone(),
373 model: raw.to_string(),
374 endpoint_key: "unproven".to_string(),
375 });
376 }
377 // A bare, unknown model on a strict direct provider is passed
378 // through verbatim (the provider validates it server-side). No
379 // offering matched, so pricing is honestly unknown (#3085).
380 Ok(ResolvedOffering::unknown(WireModelId::from(raw)))
381 }
382 // Aggregators, local runtimes, and custom OpenAI-compatible
383 // endpoints legitimately accept arbitrary / prefixed ids verbatim.
384 ProviderClass::Aggregator | ProviderClass::LocalOrCustom => {
385 let _ = provider_kind;
386 if require_catalog_match {
387 return Err(RouteError::UnsupportedModelProtocol {
388 provider: provider_id.clone(),
389 model: raw.to_string(),
390 endpoint_key: "unproven".to_string(),
391 });
392 }
393 // No offering matched: pricing is honestly unknown (#3085).
394 Ok(ResolvedOffering::unknown(WireModelId::from(raw)))
395 }
396 }
397 }
398
399 fn default_offering(&self, provider_id: &ProviderId) -> Option<&ProviderModelOffering> {
400 self.offerings
401 .iter()
402 .find(|offering| offering.provider == *provider_id && offering.default_for_provider)
403 }
404
405 /// True when `raw` names an offering that lives on a *different* provider.
406 ///
407 /// The `wire_model_id` arm catches the common case (a bare id another
408 /// provider serves). The `canonical_model` arm covers catalog rows whose
409 /// canonical id is slash-free: Models.dev canonical ids normally contain a
410 /// namespace (`zhipuai/glm-5.2`) and are already caught by the
411 /// `namespace_hint()` guard at the call site, but a bare canonical id (or a
412 /// hand-authored offering) would slip through wire-id matching alone. It is
413 /// kept deliberately so a bare canonical selector cannot masquerade as a
414 /// pass-through model on the wrong provider.
415 fn selector_matches_other_provider_offering(
416 &self,
417 provider_id: &ProviderId,
418 raw: &str,
419 ) -> bool {
420 self.offerings.iter().any(|offering| {
421 offering.provider != *provider_id
422 && (offering.wire_model_id.as_str() == raw
423 || offering
424 .canonical_model
425 .as_ref()
426 .is_some_and(|model| model.as_str() == raw))
427 })
428 }
429 }
430
431 /// Normalize aliases whose provider wire identity is publicly documented but
432 /// intentionally absent from the offline offering catalog. Keeping this seam
433 /// provider-scoped avoids claiming unverified limits or pricing while ensuring
434 /// receipts and HTTP requests carry the exact upstream model id.
435 fn provider_scoped_wire_alias(
436 provider_kind: ProviderKind,
437 raw: &str,
438 class: ProviderClass,
439 ) -> &str {
440 if class != ProviderClass::LocalOrCustom {
441 if provider_kind == ProviderKind::Together
442 && (raw.eq_ignore_ascii_case("inkling") || raw.eq_ignore_ascii_case("together-inkling"))
443 {
444 return "thinkingmachines/inkling";
445 }
446 if provider_kind == ProviderKind::Openrouter
447 && (raw.eq_ignore_ascii_case("qwen3.7-plus")
448 || raw.eq_ignore_ascii_case("qwen-3.7-plus"))
449 {
450 return "qwen/qwen3.7-plus";
451 }
452 }
453 raw
454 }
455
456 /// Build the default resolver offerings from the bundled Models.dev asset.
457 ///
458 /// Curated transport rows win a `(provider, wire id)` collision over the asset;
459 /// all other offerings continue to come from Models.dev.
460 fn default_offerings() -> Vec<ProviderModelOffering> {
461 let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
462 let mut out = Vec::new();
463 let asset_rows = bundled_catalog_offerings()
464 .iter()
465 .map(CatalogOffering::to_offering)
466 .collect::<Vec<_>>();
467 // Seam first so it wins identity collisions, then asset-only rows follow.
468 for offering in bundled_offerings().into_iter().chain(asset_rows) {
469 let key = (
470 offering.provider.as_str().to_string(),
471 offering.wire_model_id.as_str().to_string(),
472 );
473 if seen.insert(key) {
474 out.push(offering);
475 }
476 }
477 out
478 }
479
480 /// The resolver's minimal route classification.
481 ///
482 /// Intentionally narrower than tui's `validate_route`.
483 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
484 enum ProviderClass {
485 /// Strict direct provider: rejects clearly-foreign (prefixed) selectors.
486 StrictDirect,
487 /// Aggregator: serves many catalogs under prefixed wire ids.
488 Aggregator,
489 /// Local runtime or custom OpenAI-compatible endpoint: pass-through.
490 LocalOrCustom,
491 }
492
493 /// Classify a provider kind for resolver pass-through rules.
494 ///
495 /// Only a SMALL set of providers are strict-direct. Everything else passes
496 /// through, so the resolver stays permissive by default.
497 fn classify(kind: ProviderKind) -> ProviderClass {
498 match kind {
499 // Strict first-party direct providers.
500 ProviderKind::Deepseek | ProviderKind::Zai => ProviderClass::StrictDirect,
501 // Local runtimes / custom OpenAI-compatible endpoints.
502 ProviderKind::Ollama | ProviderKind::Vllm | ProviderKind::Sglang | ProviderKind::Openai => {
503 ProviderClass::LocalOrCustom
504 }
505 // Everything else is treated as an aggregator-style pass-through.
506 _ => ProviderClass::Aggregator,
507 }
508 }
509
510 fn request_uses_custom_endpoint(
511 descriptor: &ProviderDescriptor,
512 base_url_override: Option<&str>,
513 ) -> bool {
514 base_url_override
515 .is_some_and(|base_url| provider_preserves_custom_base_url_model(descriptor.kind, base_url))
516 }
517
518 fn deepseek_versioned_model_prefers_responses(model: &str) -> bool {
519 model
520 .trim()
521 .to_ascii_lowercase()
522 .strip_prefix("deepseek-v")
523 .and_then(|suffix| suffix.chars().next())
524 .is_some_and(|first| first.is_ascii_digit())
525 }
526
527 /// True when `base_url` is an `http://` endpoint whose host is NOT loopback
528 /// (#1519). Such an endpoint sends credentials in plaintext over the network;
529 /// loopback (`localhost` / `127.0.0.1` / `::1`) is exempt because local
530 /// runtimes (Ollama / vLLM / SGLang) default to plain `http://localhost`.
531 fn endpoint_uses_insecure_http(base_url: &str) -> bool {
532 let trimmed = base_url.trim();
533 // Scheme match is case-insensitive but must be `http`, not `https`.
534 let Some(rest) = strip_http_scheme(trimmed) else {
535 return false;
536 };
537 !is_loopback_host(host_of_authority(rest))
538 }
539
540 /// Strip a leading case-insensitive `http://` scheme, returning the remainder.
541 /// Returns `None` for any other scheme (including `https://`) or no scheme.
542 fn strip_http_scheme(base_url: &str) -> Option<&str> {
543 let idx = base_url.find("://")?;
544 let (scheme, rest) = base_url.split_at(idx);
545 if scheme.eq_ignore_ascii_case("http") {
546 Some(&rest[3..])
547 } else {
548 None
549 }
550 }
551
552 /// Extract the bare host from an authority+path string: take the authority up
553 /// to the first `/`, drop any `user@` userinfo and `:port` suffix, and unwrap
554 /// `[..]` IPv6 brackets.
555 fn host_of_authority(rest: &str) -> &str {
556 let authority = rest.split('/').next().unwrap_or(rest);
557 // Drop userinfo (`user:pass@host`) if present.
558 let authority = authority.rsplit('@').next().unwrap_or(authority);
559 if let Some(inner) = authority.strip_prefix('[') {
560 // Bracketed IPv6 literal: host is everything up to the closing bracket.
561 return inner.split(']').next().unwrap_or(inner);
562 }
563 // Otherwise strip a trailing `:port`.
564 authority.split(':').next().unwrap_or(authority)
565 }
566
567 /// Whether `host` is an IPv4/IPv6/name loopback address.
568 fn is_loopback_host(host: &str) -> bool {
569 let host = host.trim().trim_matches(|c| c == '[' || c == ']');
570 if host.eq_ignore_ascii_case("localhost") {
571 return true;
572 }
573 // Parse real addresses rather than pattern-matching a `127.` prefix: the
574 // old `strip_prefix("127.") && 4 dot-parts` check classified
575 // `127.evil.example.com` as loopback (2026-08-04 review), which would let
576 // a hostile hostname inherit local-trust routing. `Ipv4Addr::is_loopback`
577 // is exactly the 127.0.0.0/8 block; `Ipv6Addr::is_loopback` is `::1`.
578 if let Ok(v4) = host.parse::<std::net::Ipv4Addr>() {
579 return v4.is_loopback();
580 }
581 if let Ok(v6) = host.parse::<std::net::Ipv6Addr>() {
582 return v6.is_loopback();
583 }
584 false
585 }
586
587 #[cfg(test)]
588 mod loopback_tests {
589 use super::{endpoint_uses_insecure_http, is_loopback_host};
590
591 #[test]
592 fn loopback_matches_only_real_loopback_addresses() {
593 assert!(is_loopback_host("localhost"));
594 assert!(is_loopback_host("LocalHost"));
595 assert!(is_loopback_host("127.0.0.1"));
596 assert!(is_loopback_host("127.1.2.3")); // all of 127.0.0.0/8
597 assert!(is_loopback_host("::1"));
598 assert!(is_loopback_host("[::1]"));
599
600 // The 2026-08-04 regression: a hostile hostname that merely starts
601 // with `127.` and has four dot-parts must NOT be trusted as local.
602 assert!(!is_loopback_host("127.evil.example.com"));
603 assert!(!is_loopback_host("127.0.0.1.evil.com"));
604 assert!(!is_loopback_host("notlocalhost"));
605 assert!(!is_loopback_host("10.0.0.1"));
606 assert!(!is_loopback_host("localhost.evil.com"));
607 }
608
609 #[test]
610 fn insecure_http_flags_a_hostile_127_lookalike() {
611 // loopback stays exempt (local runtimes use plain http)
612 assert!(!endpoint_uses_insecure_http("http://127.0.0.1:11434/v1"));
613 assert!(!endpoint_uses_insecure_http("http://localhost:8000/v1"));
614 // a real remote host dressed up as 127.* is insecure http
615 assert!(endpoint_uses_insecure_http(
616 "http://127.evil.example.com/v1"
617 ));
618 }
619 }
620
620 lines RUST