Skip to main content

nono_proxy/
route.rs

1//! Route store: per-route configuration independent of credentials.
2//!
3//! `RouteStore` holds the route-level configuration (upstream URL, L7 endpoint
4//! rules, custom TLS CA) for **all** configured routes, regardless of whether
5//! they have a credential attached. This decouples L7 filtering from credential
6//! injection — a route can enforce endpoint restrictions without injecting any
7//! secret.
8//!
9//! The `CredentialStore` remains responsible for credential-specific fields
10//! (inject mode, header name/value, raw secret). Both stores are keyed by the
11//! normalised route prefix and are consulted independently by the proxy handlers.
12
13use crate::config::{CompiledEndpointRules, RouteConfig};
14use crate::error::{ProxyError, Result};
15use nono::undo::{NetworkAuditAuthMechanism, NetworkAuditInjectionMode};
16use rustls::pki_types::pem::PemObject;
17use std::collections::HashMap;
18use std::sync::Arc;
19use tracing::debug;
20use zeroize::Zeroizing;
21
22/// Route-level configuration loaded at proxy startup.
23///
24/// Contains everything needed to forward and filter a request for a route,
25/// but no credential material. Credential injection is handled separately
26/// by `CredentialStore`.
27pub struct LoadedRoute {
28    /// Upstream URL (e.g., "https://api.openai.com")
29    pub upstream: String,
30
31    /// Pre-normalised `host:port` extracted from `upstream` at load time.
32    /// Used for O(1) lookups in `is_route_upstream()` without per-request
33    /// URL parsing. `None` if the upstream URL cannot be parsed.
34    pub upstream_host_port: Option<String>,
35
36    /// Pre-compiled L7 endpoint rules for method+path filtering.
37    /// When non-empty, only matching requests are allowed (default-deny).
38    /// When empty, all method+path combinations are permitted.
39    pub endpoint_rules: CompiledEndpointRules,
40
41    /// Per-route TLS connector with custom CA trust, if configured.
42    /// Built once at startup from the route's `tls_ca` certificate file.
43    /// When `None`, the shared default connector (webpki roots only) is used.
44    pub tls_connector: Option<tokio_rustls::TlsConnector>,
45
46    /// `true` if this route requires L7 visibility — i.e. it declares
47    /// `credential_key`, `oauth2`, or non-empty `endpoint_rules` and would
48    /// not function as a transparent CONNECT tunnel. Computed once at load
49    /// time so the CONNECT dispatch path doesn't have to re-derive it on
50    /// every request.
51    pub requires_intercept: bool,
52
53    /// `true` if this route was configured to use a managed credential
54    /// source (`credential_key` or `oauth2`). Unlike `requires_intercept`,
55    /// this specifically captures whether the proxy must supply upstream
56    /// authentication itself rather than accept agent-provided credentials.
57    pub requires_managed_credential: bool,
58
59    /// Audit auth mechanism implied by the managed credential configuration.
60    /// Kept even if credential material failed to load so fail-closed denial
61    /// events can describe what auth shape the route expected.
62    pub managed_auth_mechanism: Option<NetworkAuditAuthMechanism>,
63
64    /// Audit injection mode implied by the managed credential configuration.
65    pub managed_injection_mode: Option<NetworkAuditInjectionMode>,
66}
67
68impl std::fmt::Debug for LoadedRoute {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        f.debug_struct("LoadedRoute")
71            .field("upstream", &self.upstream)
72            .field("upstream_host_port", &self.upstream_host_port)
73            .field("endpoint_rules", &self.endpoint_rules)
74            .field("has_custom_tls_ca", &self.tls_connector.is_some())
75            .field("requires_intercept", &self.requires_intercept)
76            .field(
77                "requires_managed_credential",
78                &self.requires_managed_credential,
79            )
80            .field("managed_auth_mechanism", &self.managed_auth_mechanism)
81            .field("managed_injection_mode", &self.managed_injection_mode)
82            .finish()
83    }
84}
85
86fn auth_mechanism_for_route(route: &RouteConfig) -> Option<NetworkAuditAuthMechanism> {
87    if route.oauth2.is_some() {
88        return Some(NetworkAuditAuthMechanism::PhantomHeader);
89    }
90
91    if route.credential_key.is_some() {
92        let proxy_mode = route
93            .proxy
94            .as_ref()
95            .and_then(|p| p.inject_mode.clone())
96            .unwrap_or_else(|| route.inject_mode.clone());
97        return Some(match proxy_mode {
98            crate::config::InjectMode::Header | crate::config::InjectMode::BasicAuth => {
99                NetworkAuditAuthMechanism::PhantomHeader
100            }
101            crate::config::InjectMode::UrlPath => NetworkAuditAuthMechanism::PhantomPath,
102            crate::config::InjectMode::QueryParam => NetworkAuditAuthMechanism::PhantomQuery,
103        });
104    }
105
106    None
107}
108
109fn injection_mode_for_route(route: &RouteConfig) -> Option<NetworkAuditInjectionMode> {
110    if route.oauth2.is_some() {
111        return Some(NetworkAuditInjectionMode::OAuth2);
112    }
113
114    if route.credential_key.is_some() {
115        return Some(match route.inject_mode {
116            crate::config::InjectMode::Header => NetworkAuditInjectionMode::Header,
117            crate::config::InjectMode::UrlPath => NetworkAuditInjectionMode::UrlPath,
118            crate::config::InjectMode::QueryParam => NetworkAuditInjectionMode::QueryParam,
119            crate::config::InjectMode::BasicAuth => NetworkAuditInjectionMode::BasicAuth,
120        });
121    }
122
123    None
124}
125
126/// Store of all configured routes, keyed by normalised prefix.
127///
128/// Loaded at proxy startup for **all** routes in the config, not just those
129/// with credentials. This ensures L7 endpoint filtering and upstream routing
130/// work independently of credential presence.
131#[derive(Debug)]
132pub struct RouteStore {
133    routes: HashMap<String, LoadedRoute>,
134}
135
136impl RouteStore {
137    /// Load route configuration for all configured routes.
138    ///
139    /// Each route's endpoint rules are compiled at startup so the hot path
140    /// does a regex match, not a glob compile. Routes with a `tls_ca` field
141    /// get a per-route TLS connector built from the custom CA certificate.
142    pub fn load(routes: &[RouteConfig]) -> Result<Self> {
143        let mut loaded = HashMap::new();
144
145        let base_root_store = build_base_root_store();
146
147        for route in routes {
148            let normalized_prefix = route.prefix.trim_matches('/').to_string();
149
150            debug!(
151                "Loading route '{}' -> {}",
152                normalized_prefix, route.upstream
153            );
154
155            let endpoint_rules = CompiledEndpointRules::compile(&route.endpoint_rules)
156                .map_err(|e| ProxyError::Config(format!("route '{}': {}", normalized_prefix, e)))?;
157
158            let tls_connector = if route.tls_ca.is_some()
159                || route.tls_client_cert.is_some()
160                || route.tls_client_key.is_some()
161            {
162                debug!(
163                    "Building TLS connector for route '{}' (ca={}, client_cert={})",
164                    normalized_prefix,
165                    route.tls_ca.is_some(),
166                    route.tls_client_cert.is_some(),
167                );
168                Some(build_tls_connector(
169                    &base_root_store,
170                    route.tls_ca.as_deref(),
171                    route.tls_client_cert.as_deref(),
172                    route.tls_client_key.as_deref(),
173                )?)
174            } else {
175                None
176            };
177
178            let upstream_host_port = extract_host_port(&route.upstream);
179
180            // A route needs L7 visibility if it carries credentials to inject
181            // (`credential_key` or `oauth2`) or if it enforces method/path
182            // rules. Routes without any of these are purely declarative —
183            // they exist to provide a `*_BASE_URL` env var or appear in
184            // `route_upstream_hosts()` — and CONNECT to those still gets
185            // blocked with 403 (the "force SDK cooperation" path).
186            let requires_managed_credential =
187                route.credential_key.is_some() || route.oauth2.is_some();
188            let requires_intercept =
189                requires_managed_credential || !route.endpoint_rules.is_empty();
190            let managed_auth_mechanism = auth_mechanism_for_route(route);
191            let managed_injection_mode = injection_mode_for_route(route);
192
193            loaded.insert(
194                normalized_prefix,
195                LoadedRoute {
196                    upstream: route.upstream.clone(),
197                    upstream_host_port,
198                    endpoint_rules,
199                    tls_connector,
200                    requires_intercept,
201                    requires_managed_credential,
202                    managed_auth_mechanism,
203                    managed_injection_mode,
204                },
205            );
206        }
207
208        Ok(Self { routes: loaded })
209    }
210
211    /// Create an empty route store (no routes configured).
212    #[must_use]
213    pub fn empty() -> Self {
214        Self {
215            routes: HashMap::new(),
216        }
217    }
218
219    /// Get a loaded route by normalised prefix, if configured.
220    #[must_use]
221    pub fn get(&self, prefix: &str) -> Option<&LoadedRoute> {
222        self.routes.get(prefix)
223    }
224
225    /// Check if any routes are loaded.
226    #[must_use]
227    pub fn is_empty(&self) -> bool {
228        self.routes.is_empty()
229    }
230
231    /// Number of loaded routes.
232    #[must_use]
233    pub fn len(&self) -> usize {
234        self.routes.len()
235    }
236
237    /// Check whether `host_port` (e.g. `"api.openai.com:443"`) matches
238    /// any route's upstream URL. Uses pre-normalised `host:port` strings
239    /// computed at load time to avoid per-request URL parsing.
240    #[must_use]
241    pub fn is_route_upstream(&self, host_port: &str) -> bool {
242        let normalised = host_port.to_lowercase();
243        self.routes.values().any(|route| {
244            route
245                .upstream_host_port
246                .as_ref()
247                .is_some_and(|hp| *hp == normalised)
248        })
249    }
250
251    /// Return the first route matching `host:port`, or `None`.
252    ///
253    /// Prefer [`lookup_all_by_upstream`](Self::lookup_all_by_upstream)
254    /// when multiple routes may share the same upstream.
255    #[must_use]
256    pub fn lookup_by_upstream(&self, host_port: &str) -> Option<(&str, &LoadedRoute)> {
257        let normalised = host_port.to_lowercase();
258        self.routes.iter().find_map(|(prefix, route)| {
259            route
260                .upstream_host_port
261                .as_ref()
262                .filter(|hp| **hp == normalised)
263                .map(|_| (prefix.as_str(), route))
264        })
265    }
266
267    /// Return all routes whose upstream matches `host:port`, sorted by
268    /// prefix for deterministic iteration.
269    #[must_use]
270    pub fn lookup_all_by_upstream(&self, host_port: &str) -> Vec<(&str, &LoadedRoute)> {
271        let normalised = host_port.to_lowercase();
272        let mut matches: Vec<_> = self
273            .routes
274            .iter()
275            .filter(|(_, route)| {
276                route
277                    .upstream_host_port
278                    .as_ref()
279                    .is_some_and(|hp| *hp == normalised)
280            })
281            .map(|(prefix, route)| (prefix.as_str(), route))
282            .collect();
283        matches.sort_by_key(|(prefix, _)| *prefix);
284        matches
285    }
286
287    /// Whether any route for `host:port` requires TLS interception.
288    #[must_use]
289    pub fn has_intercept_route(&self, host_port: &str) -> bool {
290        let normalised = host_port.to_lowercase();
291        self.routes.values().any(|route| {
292            route
293                .upstream_host_port
294                .as_ref()
295                .is_some_and(|hp| *hp == normalised)
296                && route.requires_intercept
297        })
298    }
299
300    /// All unique upstream `host:port` strings across loaded routes.
301    #[must_use]
302    pub fn route_upstream_hosts(&self) -> std::collections::HashSet<String> {
303        self.routes
304            .values()
305            .filter_map(|route| route.upstream_host_port.clone())
306            .collect()
307    }
308}
309
310impl LoadedRoute {
311    /// Whether this route is configured to require a proxy-managed credential
312    /// but the credential material is currently unavailable.
313    #[must_use]
314    pub fn missing_managed_credential(
315        &self,
316        has_static_credential: bool,
317        has_oauth2: bool,
318    ) -> bool {
319        self.requires_managed_credential && !has_static_credential && !has_oauth2
320    }
321}
322
323/// Extract and normalise `host:port` from a URL string.
324///
325/// Defaults to port 443 for `https://` and 80 for `http://` when no
326/// explicit port is present. Returns `None` if the URL cannot be parsed.
327fn extract_host_port(url: &str) -> Option<String> {
328    let parsed = url::Url::parse(url).ok()?;
329    let host = parsed.host_str()?;
330    let default_port = match parsed.scheme() {
331        "https" => 443,
332        "http" => 80,
333        _ => return None,
334    };
335    let port = parsed.port().unwrap_or(default_port);
336    Some(format!("{}:{}", host.to_lowercase(), port))
337}
338
339/// Read a PEM file, producing a clear `ProxyError::Config` for common failure modes.
340///
341/// Distinguishes:
342/// - file not found  → "… not found: '…'"
343/// - permission denied → "… permission denied: '…'" (nono process lacks read access)
344/// - other I/O errors  → "failed to read … '…': {os error}"
345fn read_pem_file(path: &std::path::Path, label: &str) -> Result<Zeroizing<Vec<u8>>> {
346    std::fs::read(path)
347        .map(Zeroizing::new)
348        .map_err(|e| match e.kind() {
349            std::io::ErrorKind::NotFound => {
350                ProxyError::Config(format!("{} file not found: '{}'", label, path.display()))
351            }
352            std::io::ErrorKind::PermissionDenied => ProxyError::Config(format!(
353                "{} permission denied: '{}' (check that nono can read this file)",
354                label,
355                path.display()
356            )),
357            _ => ProxyError::Config(format!(
358                "failed to read {} '{}': {}",
359                label,
360                path.display(),
361                e
362            )),
363        })
364}
365
366/// Root cert store combining webpki roots with the OS trust store.
367///
368/// Loaded once at startup and cloned into each per-route connector.
369fn build_base_root_store() -> rustls::RootCertStore {
370    let mut store = rustls::RootCertStore::empty();
371    store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
372    let native = rustls_native_certs::load_native_certs();
373    for cert in native.certs {
374        if let Err(e) = store.add(cert) {
375            debug!("skipping unparseable native cert: {e}");
376        }
377    }
378    store
379}
380
381/// Build a per-route `TlsConnector`, optionally adding a custom CA
382/// and/or mTLS client certificate on top of `base_root_store`.
383fn build_tls_connector(
384    base_root_store: &rustls::RootCertStore,
385    ca_path: Option<&str>,
386    client_cert_path: Option<&str>,
387    client_key_path: Option<&str>,
388) -> Result<tokio_rustls::TlsConnector> {
389    let mut root_store = base_root_store.clone();
390
391    // Add custom CA if provided
392    if let Some(ca_path) = ca_path {
393        let ca_path = std::path::Path::new(ca_path);
394        let ca_pem = read_pem_file(ca_path, "CA certificate")?;
395
396        let certs: Vec<_> = rustls::pki_types::CertificateDer::pem_slice_iter(ca_pem.as_ref())
397            .collect::<std::result::Result<Vec<_>, _>>()
398            .map_err(|e| {
399                ProxyError::Config(format!(
400                    "failed to parse CA certificate '{}': {}",
401                    ca_path.display(),
402                    e
403                ))
404            })?;
405
406        if certs.is_empty() {
407            return Err(ProxyError::Config(format!(
408                "CA certificate file '{}' contains no valid PEM certificates",
409                ca_path.display()
410            )));
411        }
412
413        for cert in certs {
414            root_store.add(cert).map_err(|e| {
415                ProxyError::Config(format!(
416                    "invalid CA certificate in '{}': {}",
417                    ca_path.display(),
418                    e
419                ))
420            })?;
421        }
422    }
423
424    let builder = rustls::ClientConfig::builder_with_provider(Arc::new(
425        rustls::crypto::ring::default_provider(),
426    ))
427    .with_safe_default_protocol_versions()
428    .map_err(|e| ProxyError::Config(format!("TLS config error: {}", e)))?
429    .with_root_certificates(root_store);
430
431    // Add client certificate for mTLS if provided
432    let tls_config = match (client_cert_path, client_key_path) {
433        (Some(cert_path), Some(key_path)) => {
434            let cert_path = std::path::Path::new(cert_path);
435            let key_path = std::path::Path::new(key_path);
436
437            let cert_pem = read_pem_file(cert_path, "client certificate")?;
438            let key_pem = read_pem_file(key_path, "client key")?;
439
440            let cert_chain: Vec<rustls::pki_types::CertificateDer> =
441                rustls::pki_types::CertificateDer::pem_slice_iter(cert_pem.as_ref())
442                    .collect::<std::result::Result<Vec<_>, _>>()
443                    .map_err(|e| {
444                        ProxyError::Config(format!(
445                            "failed to parse client certificate '{}': {}",
446                            cert_path.display(),
447                            e
448                        ))
449                    })?;
450
451            if cert_chain.is_empty() {
452                return Err(ProxyError::Config(format!(
453                    "client certificate file '{}' contains no valid PEM certificates",
454                    cert_path.display()
455                )));
456            }
457
458            let private_key = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_ref())
459                .map_err(|e| match e {
460                    rustls::pki_types::pem::Error::NoItemsFound => ProxyError::Config(format!(
461                        "client key file '{}' contains no valid PEM private key",
462                        key_path.display()
463                    )),
464                    _ => ProxyError::Config(format!(
465                        "failed to parse client key '{}': {}",
466                        key_path.display(),
467                        e
468                    )),
469                })?;
470
471            builder
472                .with_client_auth_cert(cert_chain, private_key)
473                .map_err(|e| {
474                    ProxyError::Config(format!(
475                        "invalid client certificate/key pair ('{}', '{}'): {}",
476                        cert_path.display(),
477                        key_path.display(),
478                        e
479                    ))
480                })?
481        }
482        (Some(_), None) => {
483            return Err(ProxyError::Config(
484                "tls_client_cert is set but tls_client_key is missing".to_string(),
485            ));
486        }
487        (None, Some(_)) => {
488            return Err(ProxyError::Config(
489                "tls_client_key is set but tls_client_cert is missing".to_string(),
490            ));
491        }
492        (None, None) => builder.with_no_client_auth(),
493    };
494
495    // Disable TLS session resumption when client certificates are configured.
496    //
497    // With TLS 1.3 PSK resumption the server may skip the CertificateRequest
498    // handshake message, so the client certificate is never re-presented on
499    // resumed connections. Servers that authenticate via x509 client certs
500    // (e.g. Kubernetes API servers) then reject or hang the request because
501    // the client identity is not established. Forcing a full handshake every
502    // time ensures the client certificate is always sent.
503    let mut tls_config = tls_config;
504    if client_cert_path.is_some() {
505        tls_config.resumption = rustls::client::Resumption::disabled();
506    }
507
508    Ok(tokio_rustls::TlsConnector::from(Arc::new(tls_config)))
509}
510
511/// Compatibility shim: build a connector with only a custom CA (no client cert).
512#[cfg(test)]
513fn build_tls_connector_with_ca(ca_path: &str) -> Result<tokio_rustls::TlsConnector> {
514    let base = build_base_root_store();
515    build_tls_connector(&base, Some(ca_path), None, None)
516}
517
518#[cfg(test)]
519#[allow(clippy::unwrap_used)]
520mod tests {
521    use super::*;
522    use crate::config::EndpointRule;
523
524    #[test]
525    fn test_empty_route_store() {
526        let store = RouteStore::empty();
527        assert!(store.is_empty());
528        assert_eq!(store.len(), 0);
529        assert!(store.get("openai").is_none());
530    }
531
532    #[test]
533    fn test_load_routes_without_credentials() {
534        // Routes without credential_key should still be loaded into RouteStore
535        let routes = vec![RouteConfig {
536            prefix: "/openai".to_string(),
537            upstream: "https://api.openai.com".to_string(),
538            credential_key: None,
539            inject_mode: Default::default(),
540            inject_header: "Authorization".to_string(),
541            credential_format: Some("Bearer {}".to_string()),
542            path_pattern: None,
543            path_replacement: None,
544            query_param_name: None,
545            proxy: None,
546            env_var: None,
547            endpoint_rules: vec![
548                EndpointRule {
549                    method: "POST".to_string(),
550                    path: "/v1/chat/completions".to_string(),
551                },
552                EndpointRule {
553                    method: "GET".to_string(),
554                    path: "/v1/models".to_string(),
555                },
556            ],
557            tls_ca: None,
558            tls_client_cert: None,
559            tls_client_key: None,
560            oauth2: None,
561        }];
562
563        let store = RouteStore::load(&routes).unwrap();
564        assert_eq!(store.len(), 1);
565
566        let route = store.get("openai").unwrap();
567        assert_eq!(route.upstream, "https://api.openai.com");
568        assert!(
569            route
570                .endpoint_rules
571                .is_allowed("POST", "/v1/chat/completions")
572        );
573        assert!(route.endpoint_rules.is_allowed("GET", "/v1/models"));
574        assert!(
575            !route
576                .endpoint_rules
577                .is_allowed("DELETE", "/v1/files/file-123")
578        );
579    }
580
581    #[test]
582    fn test_load_routes_normalises_prefix() {
583        let routes = vec![RouteConfig {
584            prefix: "/anthropic/".to_string(),
585            upstream: "https://api.anthropic.com".to_string(),
586            credential_key: None,
587            inject_mode: Default::default(),
588            inject_header: "Authorization".to_string(),
589            credential_format: Some("Bearer {}".to_string()),
590            path_pattern: None,
591            path_replacement: None,
592            query_param_name: None,
593            proxy: None,
594            env_var: None,
595            endpoint_rules: vec![],
596            tls_ca: None,
597            tls_client_cert: None,
598            tls_client_key: None,
599            oauth2: None,
600        }];
601
602        let store = RouteStore::load(&routes).unwrap();
603        assert!(store.get("anthropic").is_some());
604        assert!(store.get("/anthropic/").is_none());
605    }
606
607    #[test]
608    fn test_is_route_upstream() {
609        let routes = vec![RouteConfig {
610            prefix: "openai".to_string(),
611            upstream: "https://api.openai.com".to_string(),
612            credential_key: None,
613            inject_mode: Default::default(),
614            inject_header: "Authorization".to_string(),
615            credential_format: Some("Bearer {}".to_string()),
616            path_pattern: None,
617            path_replacement: None,
618            query_param_name: None,
619            proxy: None,
620            env_var: None,
621            endpoint_rules: vec![],
622            tls_ca: None,
623            tls_client_cert: None,
624            tls_client_key: None,
625            oauth2: None,
626        }];
627
628        let store = RouteStore::load(&routes).unwrap();
629        assert!(store.is_route_upstream("api.openai.com:443"));
630        assert!(!store.is_route_upstream("github.com:443"));
631    }
632
633    #[test]
634    fn test_route_upstream_hosts() {
635        let routes = vec![
636            RouteConfig {
637                prefix: "openai".to_string(),
638                upstream: "https://api.openai.com".to_string(),
639                credential_key: None,
640                inject_mode: Default::default(),
641                inject_header: "Authorization".to_string(),
642                credential_format: Some("Bearer {}".to_string()),
643                path_pattern: None,
644                path_replacement: None,
645                query_param_name: None,
646                proxy: None,
647                env_var: None,
648                endpoint_rules: vec![],
649                tls_ca: None,
650                tls_client_cert: None,
651                tls_client_key: None,
652                oauth2: None,
653            },
654            RouteConfig {
655                prefix: "anthropic".to_string(),
656                upstream: "https://api.anthropic.com".to_string(),
657                credential_key: None,
658                inject_mode: Default::default(),
659                inject_header: "Authorization".to_string(),
660                credential_format: Some("Bearer {}".to_string()),
661                path_pattern: None,
662                path_replacement: None,
663                query_param_name: None,
664                proxy: None,
665                env_var: None,
666                endpoint_rules: vec![],
667                tls_ca: None,
668                tls_client_cert: None,
669                tls_client_key: None,
670                oauth2: None,
671            },
672        ];
673
674        let store = RouteStore::load(&routes).unwrap();
675        let hosts = store.route_upstream_hosts();
676        assert!(hosts.contains("api.openai.com:443"));
677        assert!(hosts.contains("api.anthropic.com:443"));
678        assert_eq!(hosts.len(), 2);
679    }
680
681    #[test]
682    fn test_extract_host_port_https() {
683        assert_eq!(
684            extract_host_port("https://api.openai.com"),
685            Some("api.openai.com:443".to_string())
686        );
687    }
688
689    #[test]
690    fn test_extract_host_port_with_port() {
691        assert_eq!(
692            extract_host_port("https://api.example.com:8443"),
693            Some("api.example.com:8443".to_string())
694        );
695    }
696
697    #[test]
698    fn test_extract_host_port_http() {
699        assert_eq!(
700            extract_host_port("http://internal-service"),
701            Some("internal-service:80".to_string())
702        );
703    }
704
705    #[test]
706    fn test_extract_host_port_normalises_case() {
707        assert_eq!(
708            extract_host_port("https://API.Example.COM"),
709            Some("api.example.com:443".to_string())
710        );
711    }
712
713    #[test]
714    fn test_loaded_route_debug() {
715        let route = LoadedRoute {
716            upstream: "https://api.openai.com".to_string(),
717            upstream_host_port: Some("api.openai.com:443".to_string()),
718            endpoint_rules: CompiledEndpointRules::compile(&[]).unwrap(),
719            tls_connector: None,
720            requires_intercept: false,
721            requires_managed_credential: false,
722            managed_auth_mechanism: None,
723            managed_injection_mode: None,
724        };
725        let debug_output = format!("{:?}", route);
726        assert!(debug_output.contains("api.openai.com"));
727        assert!(debug_output.contains("has_custom_tls_ca"));
728        assert!(debug_output.contains("requires_intercept"));
729        assert!(debug_output.contains("requires_managed_credential"));
730        assert!(debug_output.contains("managed_auth_mechanism"));
731        assert!(debug_output.contains("managed_injection_mode"));
732    }
733
734    #[test]
735    fn test_requires_intercept_credential_only() {
736        let routes = vec![RouteConfig {
737            prefix: "openai".to_string(),
738            upstream: "https://api.openai.com".to_string(),
739            credential_key: Some("openai_api_key".to_string()),
740            inject_mode: Default::default(),
741            inject_header: "Authorization".to_string(),
742            credential_format: Some("Bearer {}".to_string()),
743            path_pattern: None,
744            path_replacement: None,
745            query_param_name: None,
746            proxy: None,
747            env_var: None,
748            endpoint_rules: vec![],
749            tls_ca: None,
750            tls_client_cert: None,
751            tls_client_key: None,
752            oauth2: None,
753        }];
754        let store = RouteStore::load(&routes).unwrap();
755        let hit = store.lookup_by_upstream("api.openai.com:443").unwrap();
756        assert!(store.has_intercept_route("api.openai.com:443"));
757        assert!(hit.1.requires_managed_credential);
758        assert_eq!(
759            hit.1.managed_auth_mechanism,
760            Some(NetworkAuditAuthMechanism::PhantomHeader)
761        );
762        assert_eq!(
763            hit.1.managed_injection_mode,
764            Some(NetworkAuditInjectionMode::Header)
765        );
766        assert!(!store.has_intercept_route("api.example.com:443"));
767    }
768
769    #[test]
770    fn test_requires_intercept_endpoint_rules_only() {
771        // L7-only route (no credential): rules alone are enough to require
772        // interception.
773        let routes = vec![RouteConfig {
774            prefix: "internal".to_string(),
775            upstream: "https://internal.example.com".to_string(),
776            credential_key: None,
777            inject_mode: Default::default(),
778            inject_header: "Authorization".to_string(),
779            credential_format: Some("Bearer {}".to_string()),
780            path_pattern: None,
781            path_replacement: None,
782            query_param_name: None,
783            proxy: None,
784            env_var: None,
785            endpoint_rules: vec![EndpointRule {
786                method: "GET".to_string(),
787                path: "/v1/items".to_string(),
788            }],
789            tls_ca: None,
790            tls_client_cert: None,
791            tls_client_key: None,
792            oauth2: None,
793        }];
794        let store = RouteStore::load(&routes).unwrap();
795        let hit = store
796            .lookup_by_upstream("internal.example.com:443")
797            .unwrap();
798        assert!(store.has_intercept_route("internal.example.com:443"));
799        assert!(!hit.1.requires_managed_credential);
800    }
801
802    #[test]
803    fn test_requires_intercept_declarative_only() {
804        // No credential, no rules — purely declarative route. CONNECT to
805        // this upstream still gets the existing 403 (not intercepted).
806        let routes = vec![RouteConfig {
807            prefix: "alias".to_string(),
808            upstream: "https://aliased.example.com".to_string(),
809            credential_key: None,
810            inject_mode: Default::default(),
811            inject_header: "Authorization".to_string(),
812            credential_format: Some("Bearer {}".to_string()),
813            path_pattern: None,
814            path_replacement: None,
815            query_param_name: None,
816            proxy: None,
817            env_var: None,
818            endpoint_rules: vec![],
819            tls_ca: None,
820            tls_client_cert: None,
821            tls_client_key: None,
822            oauth2: None,
823        }];
824        let store = RouteStore::load(&routes).unwrap();
825        assert!(store.is_route_upstream("aliased.example.com:443"));
826        assert!(!store.has_intercept_route("aliased.example.com:443"));
827    }
828
829    #[test]
830    fn test_missing_managed_credential_policy() {
831        let managed = LoadedRoute {
832            upstream: "https://api.openai.com".to_string(),
833            upstream_host_port: Some("api.openai.com:443".to_string()),
834            endpoint_rules: CompiledEndpointRules::compile(&[]).unwrap(),
835            tls_connector: None,
836            requires_intercept: true,
837            requires_managed_credential: true,
838            managed_auth_mechanism: Some(NetworkAuditAuthMechanism::PhantomHeader),
839            managed_injection_mode: Some(NetworkAuditInjectionMode::Header),
840        };
841        assert!(managed.missing_managed_credential(false, false));
842        assert!(!managed.missing_managed_credential(true, false));
843        assert!(!managed.missing_managed_credential(false, true));
844
845        let l7_only = LoadedRoute {
846            upstream: "https://internal.example.com".to_string(),
847            upstream_host_port: Some("internal.example.com:443".to_string()),
848            endpoint_rules: CompiledEndpointRules::compile(&[]).unwrap(),
849            tls_connector: None,
850            requires_intercept: true,
851            requires_managed_credential: false,
852            managed_auth_mechanism: None,
853            managed_injection_mode: None,
854        };
855        assert!(!l7_only.missing_managed_credential(false, false));
856    }
857
858    #[test]
859    fn test_lookup_by_upstream_returns_prefix() {
860        let routes = vec![RouteConfig {
861            prefix: "openai".to_string(),
862            upstream: "https://api.openai.com".to_string(),
863            credential_key: Some("openai_api_key".to_string()),
864            inject_mode: Default::default(),
865            inject_header: "Authorization".to_string(),
866            credential_format: Some("Bearer {}".to_string()),
867            path_pattern: None,
868            path_replacement: None,
869            query_param_name: None,
870            proxy: None,
871            env_var: None,
872            endpoint_rules: vec![],
873            tls_ca: None,
874            tls_client_cert: None,
875            tls_client_key: None,
876            oauth2: None,
877        }];
878        let store = RouteStore::load(&routes).unwrap();
879        let hit = store.lookup_by_upstream("api.openai.com:443").unwrap();
880        assert_eq!(hit.0, "openai");
881        assert!(hit.1.requires_intercept);
882        assert!(hit.1.requires_managed_credential);
883        assert!(store.lookup_by_upstream("api.example.com:443").is_none());
884    }
885
886    #[test]
887    fn test_lookup_all_by_upstream_returns_multiple_routes() {
888        let routes = vec![
889            RouteConfig {
890                prefix: "github_org_a".to_string(),
891                upstream: "https://github.com".to_string(),
892                credential_key: Some("env://GH_TOKEN_A".to_string()),
893                inject_mode: Default::default(),
894                inject_header: "Authorization".to_string(),
895                credential_format: Some("Bearer {}".to_string()),
896                path_pattern: None,
897                path_replacement: None,
898                query_param_name: None,
899                proxy: None,
900                env_var: Some("GH_TOKEN_A".to_string()),
901                endpoint_rules: vec![crate::config::EndpointRule {
902                    method: "*".to_string(),
903                    path: "/org-a/**".to_string(),
904                }],
905                tls_ca: None,
906                tls_client_cert: None,
907                tls_client_key: None,
908                oauth2: None,
909            },
910            RouteConfig {
911                prefix: "github_org_b".to_string(),
912                upstream: "https://github.com".to_string(),
913                credential_key: Some("env://GH_TOKEN_B".to_string()),
914                inject_mode: Default::default(),
915                inject_header: "Authorization".to_string(),
916                credential_format: Some("Bearer {}".to_string()),
917                path_pattern: None,
918                path_replacement: None,
919                query_param_name: None,
920                proxy: None,
921                env_var: Some("GH_TOKEN_B".to_string()),
922                endpoint_rules: vec![crate::config::EndpointRule {
923                    method: "*".to_string(),
924                    path: "/org-b/**".to_string(),
925                }],
926                tls_ca: None,
927                tls_client_cert: None,
928                tls_client_key: None,
929                oauth2: None,
930            },
931        ];
932        let store = RouteStore::load(&routes).unwrap();
933
934        let all = store.lookup_all_by_upstream("github.com:443");
935        assert_eq!(all.len(), 2, "both routes share the same upstream");
936
937        let prefixes: Vec<&str> = all.iter().map(|(p, _)| *p).collect();
938        assert!(prefixes.contains(&"github_org_a"));
939        assert!(prefixes.contains(&"github_org_b"));
940
941        let (_, route_a) = all.iter().find(|(p, _)| *p == "github_org_a").unwrap();
942        assert!(route_a.endpoint_rules.is_allowed("GET", "/org-a/repo"));
943        assert!(!route_a.endpoint_rules.is_allowed("GET", "/org-b/repo"));
944
945        let (_, route_b) = all.iter().find(|(p, _)| *p == "github_org_b").unwrap();
946        assert!(route_b.endpoint_rules.is_allowed("GET", "/org-b/repo"));
947        assert!(!route_b.endpoint_rules.is_allowed("GET", "/org-a/repo"));
948
949        assert!(store.has_intercept_route("github.com:443"));
950        assert!(store.is_route_upstream("github.com:443"));
951        assert!(store.lookup_all_by_upstream("other.com:443").is_empty());
952    }
953
954    /// Models a real multi-org GitHub profile. Mirrors the selection
955    /// loop in `tls_intercept::handle`:
956    ///   1 match  → inject that route's credential
957    ///   0 matches → passthrough (no credential injected)
958    ///   2+ matches → ambiguous (hard-deny 403)
959    #[test]
960    fn test_route_selection_multi_org_profile() {
961        // Helper to build a route with the given prefix and endpoint path.
962        fn gh_route(prefix: &str, env: &str, path: &str) -> RouteConfig {
963            RouteConfig {
964                prefix: prefix.to_string(),
965                upstream: "https://github.com".to_string(),
966                credential_key: Some(format!("env://{env}")),
967                inject_mode: Default::default(),
968                inject_header: "Authorization".to_string(),
969                credential_format: Some("Bearer {}".to_string()),
970                path_pattern: None,
971                path_replacement: None,
972                query_param_name: None,
973                proxy: None,
974                env_var: Some(env.to_string()),
975                endpoint_rules: vec![crate::config::EndpointRule {
976                    method: "*".to_string(),
977                    path: path.to_string(),
978                }],
979                tls_ca: None,
980                tls_client_cert: None,
981                tls_client_key: None,
982                oauth2: None,
983            }
984        }
985
986        #[derive(Debug, PartialEq)]
987        enum Selection<'a> {
988            Route(&'a str),
989            Passthrough,
990            Ambiguous(Vec<&'a str>),
991        }
992
993        fn select<'a>(
994            candidates: &'a [(&'a str, &'a LoadedRoute)],
995            method: &str,
996            path: &str,
997        ) -> Selection<'a> {
998            let mut matches: Vec<&str> = Vec::new();
999            let mut catch_all: Option<&str> = None;
1000            for (prefix, route) in candidates {
1001                if route.endpoint_rules.is_empty() {
1002                    if catch_all.is_none() {
1003                        catch_all = Some(*prefix);
1004                    }
1005                } else if route.endpoint_rules.is_allowed(method, path) {
1006                    matches.push(prefix);
1007                }
1008            }
1009            if matches.len() > 1 {
1010                Selection::Ambiguous(matches)
1011            } else if let Some(svc) = matches.into_iter().next().or(catch_all) {
1012                Selection::Route(svc)
1013            } else {
1014                Selection::Passthrough
1015            }
1016        }
1017
1018        // --- Profile: two org-scoped routes, no catch-all ---
1019        let routes = vec![
1020            gh_route("github_https_org_a", "GH_TOKEN_A", "/org-a/**"),
1021            gh_route("github_https_org_b", "GH_TOKEN_B", "/org-b/**"),
1022        ];
1023        let store = RouteStore::load(&routes).unwrap();
1024        let candidates = store.lookup_all_by_upstream("github.com:443");
1025        assert_eq!(candidates.len(), 2);
1026
1027        // Private org-a repo → org-a credential
1028        assert_eq!(
1029            select(&candidates, "GET", "/org-a/repo.git/info/refs"),
1030            Selection::Route("github_https_org_a")
1031        );
1032        // Private org-b repo → org-b credential
1033        assert_eq!(
1034            select(&candidates, "GET", "/org-b/repo.git/info/refs"),
1035            Selection::Route("github_https_org_b")
1036        );
1037        // Public repo (e.g. always-further/nono) → passthrough, no cred
1038        assert_eq!(
1039            select(&candidates, "GET", "/always-further/nono.git/info/refs"),
1040            Selection::Passthrough
1041        );
1042        // POST to public repo → also passthrough
1043        assert_eq!(
1044            select(
1045                &candidates,
1046                "POST",
1047                "/always-further/nono.git/git-upload-pack"
1048            ),
1049            Selection::Passthrough
1050        );
1051
1052        // --- Adding a /** catch-all would cause ambiguity ---
1053        let routes_with_catchall = vec![
1054            gh_route("github_https_org_a", "GH_TOKEN_A", "/org-a/**"),
1055            gh_route("github_https_org_b", "GH_TOKEN_B", "/org-b/**"),
1056            gh_route("github_https_all", "GH_TOKEN_A", "/**"),
1057        ];
1058        let store2 = RouteStore::load(&routes_with_catchall).unwrap();
1059        let candidates2 = store2.lookup_all_by_upstream("github.com:443");
1060        assert_eq!(candidates2.len(), 3);
1061
1062        // org-a request now matches BOTH org_a AND the /** catch-all → ambiguous
1063        assert_eq!(
1064            select(&candidates2, "GET", "/org-a/repo.git/info/refs"),
1065            Selection::Ambiguous(vec!["github_https_all", "github_https_org_a"])
1066        );
1067        // Public repo matches only the /** catch-all → 1 match, ok
1068        assert_eq!(
1069            select(&candidates2, "GET", "/always-further/nono.git/info/refs"),
1070            Selection::Route("github_https_all")
1071        );
1072    }
1073
1074    /// Self-signed CA for testing. Generated with:
1075    /// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
1076    ///   -keyout /dev/null -nodes -days 36500 -subj '/CN=nono-test-ca' -out -
1077    const TEST_CA_PEM: &str = "\
1078-----BEGIN CERTIFICATE-----
1079MIIBnjCCAUWgAwIBAgIUT0bpOJJvHdOdZt+gW1stR8VBgXowCgYIKoZIzj0EAwIw
1080FzEVMBMGA1UEAwwMbm9uby10ZXN0LWNhMCAXDTI1MDEwMTAwMDAwMFoYDzIxMjQx
1081MjA3MDAwMDAwWjAXMRUwEwYDVQQDDAxub25vLXRlc3QtY2EwWTATBgcqhkjOPQIB
1082BggqhkjOPQMBBwNCAAR8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
1083AAAAAAAAAAAAAAAAAAAAo1MwUTAdBgNVHQ4EFgQUAAAAAAAAAAAAAAAAAAAAAAAA
1084AAAAMB8GA1UdIwQYMBaAFAAAAAAAAAAAAAAAAAAAAAAAAAAAADAPBgNVHRMBAf8E
1085BTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
1086AAAAAAAICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
1087-----END CERTIFICATE-----";
1088
1089    #[test]
1090    fn test_build_tls_connector_with_valid_ca() {
1091        let dir = tempfile::tempdir().unwrap();
1092        let ca_path = dir.path().join("ca.pem");
1093        std::fs::write(&ca_path, TEST_CA_PEM).unwrap();
1094
1095        let result = build_tls_connector_with_ca(ca_path.to_str().unwrap());
1096        match result {
1097            Ok(connector) => {
1098                drop(connector);
1099            }
1100            Err(ProxyError::Config(msg)) => {
1101                assert!(
1102                    msg.contains("invalid CA certificate") || msg.contains("CA certificate"),
1103                    "unexpected error: {}",
1104                    msg
1105                );
1106            }
1107            Err(e) => panic!("unexpected error type: {}", e),
1108        }
1109    }
1110
1111    #[test]
1112    fn test_build_tls_connector_missing_file() {
1113        let result = build_tls_connector_with_ca("/nonexistent/path/ca.pem");
1114        let err = result
1115            .err()
1116            .expect("should fail for missing file")
1117            .to_string();
1118        assert!(
1119            err.contains("CA certificate file not found"),
1120            "unexpected error: {}",
1121            err
1122        );
1123    }
1124
1125    #[test]
1126    fn test_build_tls_connector_empty_pem() {
1127        let dir = tempfile::tempdir().unwrap();
1128        let ca_path = dir.path().join("empty.pem");
1129        std::fs::write(&ca_path, "not a certificate\n").unwrap();
1130
1131        let result = build_tls_connector_with_ca(ca_path.to_str().unwrap());
1132        let err = result
1133            .err()
1134            .expect("should fail for invalid PEM")
1135            .to_string();
1136        assert!(
1137            err.contains("no valid PEM certificates"),
1138            "unexpected error: {}",
1139            err
1140        );
1141    }
1142
1143    // --- mTLS (client certificate) tests ---
1144
1145    /// Self-signed client cert + key for testing. Generated with:
1146    /// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
1147    ///   -keyout client.key -nodes -days 3650 -subj '/CN=nono-test-client' -out client.crt
1148    const TEST_CLIENT_CERT_PEM: &str = "\
1149-----BEGIN CERTIFICATE-----
1150MIIBijCCATGgAwIBAgIUEoEb+0z+4CTRCzN98MqeTEXgdO8wCgYIKoZIzj0EAwIw
1151GzEZMBcGA1UEAwwQbm9uby10ZXN0LWNsaWVudDAeFw0yNjA0MTAwMDIwNTdaFw0z
1152NjA0MDcwMDIwNTdaMBsxGTAXBgNVBAMMEG5vbm8tdGVzdC1jbGllbnQwWTATBgcq
1153hkjOPQIBBggqhkjOPQMBBwNCAASt6g2Zt0STlgF+wZ64JzdDRlpPeNr1h56ZLEEq
1154HfVWFhJWIKRSabtxYPV/VJyMv+lo3L0QwSKsouHs3dtF1zVQo1MwUTAdBgNVHQ4E
1155FgQUTiHidg8uqgrJ1qlaVvR+XSebAlEwHwYDVR0jBBgwFoAUTiHidg8uqgrJ1qla
1156VvR+XSebAlEwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNHADBEAiA9PwBU
1157f832cQkGS9cyYaU7Ij5U8Rcy/g4J7Ckf2nKX3gIgG0aarAFcIzAi5VpxbCwEScnr
1158m0lHTyp6E7ut7llwMBY=
1159-----END CERTIFICATE-----";
1160
1161    const TEST_CLIENT_KEY_PEM: &str = "\
1162-----BEGIN PRIVATE KEY-----
1163MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgskOkyJkTwlMZkm/L
1164eEleLY6bARaHFnqauYJqxNoJWvihRANCAASt6g2Zt0STlgF+wZ64JzdDRlpPeNr1
1165h56ZLEEqHfVWFhJWIKRSabtxYPV/VJyMv+lo3L0QwSKsouHs3dtF1zVQ
1166-----END PRIVATE KEY-----";
1167
1168    #[test]
1169    fn test_build_tls_connector_cert_without_key_errors() {
1170        let dir = tempfile::tempdir().unwrap();
1171        let cert_path = dir.path().join("client.crt");
1172        std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1173
1174        let base = build_base_root_store();
1175        let result = build_tls_connector(&base, None, Some(cert_path.to_str().unwrap()), None);
1176        let err = result
1177            .err()
1178            .expect("should fail with half-pair")
1179            .to_string();
1180        assert!(
1181            err.contains("tls_client_cert is set but tls_client_key is missing"),
1182            "unexpected error: {}",
1183            err
1184        );
1185    }
1186
1187    #[test]
1188    fn test_build_tls_connector_key_without_cert_errors() {
1189        let dir = tempfile::tempdir().unwrap();
1190        let key_path = dir.path().join("client.key");
1191        std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
1192
1193        let base = build_base_root_store();
1194        let result = build_tls_connector(&base, None, None, Some(key_path.to_str().unwrap()));
1195        let err = result
1196            .err()
1197            .expect("should fail with half-pair")
1198            .to_string();
1199        assert!(
1200            err.contains("tls_client_key is set but tls_client_cert is missing"),
1201            "unexpected error: {}",
1202            err
1203        );
1204    }
1205
1206    #[test]
1207    fn test_build_tls_connector_missing_client_cert_file() {
1208        let dir = tempfile::tempdir().unwrap();
1209        let key_path = dir.path().join("client.key");
1210        std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
1211
1212        let base = build_base_root_store();
1213        let result = build_tls_connector(
1214            &base,
1215            None,
1216            Some("/nonexistent/client.crt"),
1217            Some(key_path.to_str().unwrap()),
1218        );
1219        let err = result.err().expect("should fail").to_string();
1220        assert!(
1221            err.contains("client certificate file not found"),
1222            "unexpected error: {}",
1223            err
1224        );
1225    }
1226
1227    #[test]
1228    fn test_build_tls_connector_missing_client_key_file() {
1229        let dir = tempfile::tempdir().unwrap();
1230        let cert_path = dir.path().join("client.crt");
1231        std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1232
1233        let base = build_base_root_store();
1234        let result = build_tls_connector(
1235            &base,
1236            None,
1237            Some(cert_path.to_str().unwrap()),
1238            Some("/nonexistent/client.key"),
1239        );
1240        let err = result.err().expect("should fail").to_string();
1241        assert!(
1242            err.contains("client key file not found"),
1243            "unexpected error: {}",
1244            err
1245        );
1246    }
1247
1248    #[test]
1249    #[cfg(unix)]
1250    fn test_build_tls_connector_permission_denied() {
1251        use std::os::unix::fs::PermissionsExt;
1252        let dir = tempfile::tempdir().unwrap();
1253        let cert_path = dir.path().join("client.crt");
1254        std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1255        // Remove all permissions so the file exists but can't be read
1256        std::fs::set_permissions(&cert_path, std::fs::Permissions::from_mode(0o000)).unwrap();
1257
1258        // Skip if running as root (root bypasses permission checks)
1259        if std::fs::read(&cert_path).is_ok() {
1260            return;
1261        }
1262
1263        let base = build_base_root_store();
1264        let result = build_tls_connector(
1265            &base,
1266            None,
1267            Some(cert_path.to_str().unwrap()),
1268            Some("/nonexistent/key"),
1269        );
1270        let err = result.err().expect("should fail").to_string();
1271        assert!(
1272            err.contains("permission denied"),
1273            "expected permission denied error, got: {}",
1274            err
1275        );
1276    }
1277
1278    #[test]
1279    fn test_build_tls_connector_empty_client_cert_pem() {
1280        let dir = tempfile::tempdir().unwrap();
1281        let cert_path = dir.path().join("client.crt");
1282        let key_path = dir.path().join("client.key");
1283        std::fs::write(&cert_path, "not a certificate\n").unwrap();
1284        std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
1285
1286        let base = build_base_root_store();
1287        let result = build_tls_connector(
1288            &base,
1289            None,
1290            Some(cert_path.to_str().unwrap()),
1291            Some(key_path.to_str().unwrap()),
1292        );
1293        let err = result.err().expect("should fail").to_string();
1294        assert!(
1295            err.contains("no valid PEM certificates"),
1296            "unexpected error: {}",
1297            err
1298        );
1299    }
1300
1301    #[test]
1302    fn test_build_tls_connector_empty_client_key_pem() {
1303        // Verifies that an invalid key file produces an appropriate config error.
1304        let dir = tempfile::tempdir().unwrap();
1305        let cert_path = dir.path().join("client.crt");
1306        let key_path = dir.path().join("client.key");
1307        std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1308        std::fs::write(&key_path, "not a key\n").unwrap();
1309
1310        let base = build_base_root_store();
1311        let result = build_tls_connector(
1312            &base,
1313            None,
1314            Some(cert_path.to_str().unwrap()),
1315            Some(key_path.to_str().unwrap()),
1316        );
1317        let err = result
1318            .err()
1319            .expect("should fail with invalid PEM")
1320            .to_string();
1321        assert!(err.contains("client key"), "unexpected error: {}", err);
1322    }
1323
1324    #[test]
1325    fn test_route_store_loads_mtls_route() {
1326        // Verify RouteStore.load() builds a TLS connector when tls_client_cert/key are set.
1327        let dir = tempfile::tempdir().unwrap();
1328        let cert_path = dir.path().join("client.crt");
1329        let key_path = dir.path().join("client.key");
1330        std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1331        std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
1332
1333        let routes = vec![RouteConfig {
1334            prefix: "k8s".to_string(),
1335            upstream: "https://192.168.64.1:6443".to_string(),
1336            credential_key: None,
1337            inject_mode: Default::default(),
1338            inject_header: "Authorization".to_string(),
1339            credential_format: Some("Bearer {}".to_string()),
1340            path_pattern: None,
1341            path_replacement: None,
1342            query_param_name: None,
1343            proxy: None,
1344            env_var: None,
1345            endpoint_rules: vec![],
1346            tls_ca: None,
1347            tls_client_cert: Some(cert_path.to_str().unwrap().to_string()),
1348            tls_client_key: Some(key_path.to_str().unwrap().to_string()),
1349            oauth2: None,
1350        }];
1351
1352        let store = RouteStore::load(&routes).expect("should load mTLS route");
1353        let route = store.get("k8s").unwrap();
1354        assert!(
1355            route.tls_connector.is_some(),
1356            "connector must be built when tls_client_cert/key are set"
1357        );
1358    }
1359}