Skip to main content

nono_proxy/
config.rs

1//! Proxy configuration types.
2//!
3//! Defines the configuration for the proxy server, including allowed hosts,
4//! credential routes, and external proxy settings.
5
6use globset::Glob;
7use serde::{Deserialize, Serialize};
8use std::net::IpAddr;
9use std::path::PathBuf;
10use zeroize::Zeroizing;
11
12/// Credential injection mode determining how credentials are inserted into requests.
13#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum InjectMode {
16    /// Inject credential into an HTTP header (default)
17    #[default]
18    Header,
19    /// Replace a pattern in the URL path with the credential
20    UrlPath,
21    /// Add or replace a query parameter with the credential
22    QueryParam,
23    /// Use HTTP Basic Authentication (credential format: "username:password")
24    BasicAuth,
25}
26
27/// Configuration for the proxy server.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ProxyConfig {
30    /// Bind address (default: 127.0.0.1)
31    #[serde(default = "default_bind_addr")]
32    pub bind_addr: IpAddr,
33
34    /// Bind port (0 = OS-assigned ephemeral port)
35    #[serde(default)]
36    pub bind_port: u16,
37
38    /// Allowed hosts for CONNECT mode (exact match + wildcards).
39    /// Empty = allow all hosts (except deny list).
40    #[serde(default)]
41    pub allowed_hosts: Vec<String>,
42
43    /// Reverse proxy credential routes.
44    #[serde(default)]
45    pub routes: Vec<RouteConfig>,
46
47    /// External (enterprise) proxy URL for passthrough mode.
48    /// When set, CONNECT requests are chained to this proxy.
49    #[serde(default)]
50    pub external_proxy: Option<ExternalProxyConfig>,
51
52    /// Outbound TCP ports that the sandbox allows direct connections on
53    /// (via Landlock ConnectTcp). Hosts whose resolved port is NOT in this
54    /// set must go through the proxy and should NOT appear in NO_PROXY.
55    #[serde(default)]
56    pub direct_connect_ports: Vec<u16>,
57
58    /// Maximum concurrent connections (0 = unlimited).
59    #[serde(default)]
60    pub max_connections: usize,
61
62    /// Directory the proxy will write the TLS-intercept trust bundle into.
63    ///
64    /// When set together with at least one route requiring L7 visibility
65    /// (`endpoint_rules`, `credential_key`, or `oauth2`), the proxy generates
66    /// an ephemeral session CA and writes a PEM bundle (system roots +
67    /// optional parent `SSL_CERT_FILE` + ephemeral CA) into this directory at
68    /// startup. The path is exposed via `ProxyHandle::intercept_ca_path()`
69    /// so the CLI can grant the sandboxed child a Landlock/Seatbelt read
70    /// capability for it.
71    ///
72    /// The directory must exist and be owner-only readable (mode `0o700`)
73    /// before `start()` is called. The CLI conventionally points this at
74    /// `~/.nono/sessions/<session_id>/`.
75    ///
76    /// `None` disables TLS interception entirely; CONNECT requests behave
77    /// as before (transparent tunnel for non-route hosts; 403 for routes
78    /// without L7 requirements).
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub intercept_ca_dir: Option<PathBuf>,
81
82    /// Optional contents of the parent process's `SSL_CERT_FILE`, merged
83    /// into the trust bundle so any corporate CA configured on the host
84    /// remains trusted by the sandboxed child.
85    ///
86    /// The CLI reads this from `std::env::var("SSL_CERT_FILE")` and
87    /// `std::fs::read(...)` before calling `start()`. Skipped during
88    /// (de)serialisation: it's not part of any user-authored config file.
89    #[serde(default, skip)]
90    pub intercept_parent_ca_pems: Option<Vec<u8>>,
91
92    /// Pre-generated CA material for cross-session reuse (`--trust-proxy-ca`).
93    ///
94    /// When `Some`, the proxy uses this CA instead of generating a fresh
95    /// ephemeral one. The private key was loaded from macOS Keychain by the
96    /// CLI supervisor; the cert is already trusted in the user's trust store.
97    #[serde(default, skip)]
98    pub preloaded_ca: Option<PreloadedCa>,
99
100    /// Optional CA validity override for TLS interception.
101    /// Default (`None`) uses `CA_VALIDITY_DEFAULT` (24h).
102    /// Set by CLI `--proxy-ca-validity` flag.
103    #[serde(default, skip)]
104    pub ca_validity: Option<std::time::Duration>,
105}
106
107/// Pre-generated CA key material for cross-session CA reuse.
108///
109/// Used by `--trust-proxy-ca` on macOS: the CLI persists the CA in Keychain
110/// and passes it to the proxy so all sessions within the CA's validity window
111/// share the same signing key (and the same trusted cert in the system store).
112///
113/// ## Security note
114///
115/// The Keychain item's access control depends on the binary's code-signing
116/// identity. Release-signed builds get per-app isolation; unsigned dev builds
117/// allow any local process to read the key.
118///
119/// Because the CA is trusted user-wide during its validity window, any
120/// same-user process that can read the Keychain item could mint certificates
121/// trusted by macOS trust consumers. Release-signed builds are expected to
122/// receive stronger Keychain access isolation than unsigned development builds.
123/// The configurable CA validity (`--proxy-ca-validity`) limits exposure.
124#[derive(Clone)]
125pub struct PreloadedCa {
126    /// PKCS#8 DER-encoded private key for the CA. Zeroized on drop.
127    pub key_der: Zeroizing<Vec<u8>>,
128    /// PEM-encoded CA certificate (public).
129    pub cert_pem: String,
130}
131
132impl std::fmt::Debug for PreloadedCa {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        f.debug_struct("PreloadedCa")
135            .field("key_der", &"[REDACTED]")
136            .field("cert_pem_len", &self.cert_pem.len())
137            .finish()
138    }
139}
140
141impl Default for ProxyConfig {
142    fn default() -> Self {
143        Self {
144            bind_addr: default_bind_addr(),
145            bind_port: 0,
146            allowed_hosts: Vec::new(),
147            routes: Vec::new(),
148            external_proxy: None,
149            direct_connect_ports: Vec::new(),
150            max_connections: 256,
151            intercept_ca_dir: None,
152            intercept_parent_ca_pems: None,
153            preloaded_ca: None,
154            ca_validity: None,
155        }
156    }
157}
158
159fn default_bind_addr() -> IpAddr {
160    IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
161}
162
163/// Configuration for a reverse proxy credential route.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct RouteConfig {
166    /// Path prefix for routing (e.g., "openai").
167    /// Must NOT include leading or trailing slashes — it is a bare service name, not a URL path.
168    pub prefix: String,
169
170    /// Upstream URL to forward to (e.g., "https://api.openai.com")
171    pub upstream: String,
172
173    /// Keystore account name to load the credential from.
174    /// If `None`, no credential is injected.
175    pub credential_key: Option<String>,
176
177    /// Injection mode (default: "header")
178    #[serde(default)]
179    pub inject_mode: InjectMode,
180
181    // --- Header mode fields ---
182    /// HTTP header name for the credential (default: "Authorization")
183    /// Only used when inject_mode is "header".
184    #[serde(default = "default_inject_header")]
185    pub inject_header: String,
186
187    /// How the injected header value is built (`{}` is replaced by the secret). Only when `inject_mode` is header.
188    ///
189    /// If you set this field, that whole string is used as-is — `Authorization` or any other header.
190    ///
191    /// If you omit it: an `Authorization` header (any capitalization) defaults to `Bearer {}`; any other header defaults to `{}` (secret only, no prefix).
192    #[serde(default)]
193    pub credential_format: Option<String>,
194
195    // --- URL path mode fields ---
196    /// Pattern to match in incoming URL path. Use {} as placeholder for phantom token.
197    /// Example: "/bot{}/" matches "/bot<token>/getMe"
198    /// Only used when inject_mode is "url_path".
199    #[serde(default)]
200    pub path_pattern: Option<String>,
201
202    /// Pattern for outgoing URL path. Use {} as placeholder for real credential.
203    /// Defaults to same as path_pattern if not specified.
204    /// Only used when inject_mode is "url_path".
205    #[serde(default)]
206    pub path_replacement: Option<String>,
207
208    // --- Query param mode fields ---
209    /// Name of the query parameter to add/replace with the credential.
210    /// Only used when inject_mode is "query_param".
211    #[serde(default)]
212    pub query_param_name: Option<String>,
213
214    /// Optional overrides for proxy-side phantom token handling.
215    ///
216    /// When set, these values are used to validate the incoming phantom token
217    /// from the sandboxed client request. Outbound credential injection to the
218    /// upstream continues to use the top-level route fields.
219    #[serde(default)]
220    pub proxy: Option<ProxyInjectConfig>,
221
222    /// Explicit environment variable name for the phantom token (e.g., "OPENAI_API_KEY").
223    ///
224    /// When set, this is used as the SDK API key env var name instead of deriving
225    /// it from `credential_key.to_uppercase()`. Required when `credential_key` is
226    /// a URI manager reference (e.g., `op://`, `apple-password://`) which would
227    /// otherwise produce a nonsensical env var name.
228    #[serde(default)]
229    pub env_var: Option<String>,
230
231    /// Optional L7 endpoint rules for method+path filtering.
232    ///
233    /// When non-empty, only requests matching at least one rule are allowed
234    /// (default-deny). When empty, all method+path combinations are permitted
235    /// (backward compatible).
236    #[serde(default)]
237    pub endpoint_rules: Vec<EndpointRule>,
238
239    /// Optional path to a PEM-encoded CA certificate file for upstream TLS.
240    ///
241    /// When set, the proxy trusts this CA in addition to the system roots
242    /// when connecting to the upstream for this route. This is required for
243    /// upstreams that use self-signed or private CA certificates (e.g.,
244    /// Kubernetes API servers).
245    #[serde(default)]
246    pub tls_ca: Option<String>,
247
248    /// Optional path to a PEM-encoded client certificate for upstream mTLS.
249    ///
250    /// When set together with `tls_client_key`, the proxy presents this
251    /// certificate to the upstream during TLS handshake. Required for
252    /// upstreams that enforce mutual TLS (e.g., Kubernetes API servers
253    /// configured with client-certificate authentication).
254    #[serde(default)]
255    pub tls_client_cert: Option<String>,
256
257    /// Optional path to a PEM-encoded private key for upstream mTLS.
258    ///
259    /// Must be set together with `tls_client_cert`. The key must correspond
260    /// to the certificate in `tls_client_cert`.
261    #[serde(default)]
262    pub tls_client_key: Option<String>,
263
264    /// Optional OAuth2 client_credentials configuration.
265    /// When present, the proxy handles token exchange automatically instead
266    /// of using a static credential from the keystore.
267    /// Mutually exclusive with `credential_key` — use one or the other.
268    #[serde(default)]
269    pub oauth2: Option<OAuth2Config>,
270}
271
272/// Optional proxy-side overrides for credential injection shape.
273///
274/// These settings apply only to how the proxy validates the phantom token from
275/// the client request. Any field omitted here falls back to the corresponding
276/// top-level route field.
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(deny_unknown_fields)]
279pub struct ProxyInjectConfig {
280    /// Optional injection mode override for proxy-side token parsing.
281    #[serde(default)]
282    pub inject_mode: Option<InjectMode>,
283
284    /// Optional header name override for header/basic_auth modes.
285    #[serde(default)]
286    pub inject_header: Option<String>,
287
288    /// Optional format override for header mode.
289    #[serde(default)]
290    pub credential_format: Option<String>,
291
292    /// Optional path pattern override for url_path mode.
293    #[serde(default)]
294    pub path_pattern: Option<String>,
295
296    /// Optional path replacement override for url_path mode.
297    #[serde(default)]
298    pub path_replacement: Option<String>,
299
300    /// Optional query parameter override for query_param mode.
301    #[serde(default)]
302    pub query_param_name: Option<String>,
303}
304
305/// An HTTP method+path access rule for reverse proxy endpoint filtering.
306///
307/// Used to restrict which API endpoints an agent can access through a
308/// credential route. Patterns use `/` separated segments with wildcards:
309/// - `*` matches exactly one path segment
310/// - `**` matches zero or more path segments
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312pub struct EndpointRule {
313    /// HTTP method to match ("GET", "POST", etc.) or "*" for any method.
314    pub method: String,
315    /// URL path pattern with glob segments.
316    /// Example: "/api/v4/projects/*/merge_requests/**"
317    pub path: String,
318}
319
320/// Pre-compiled endpoint rules for the request hot path.
321///
322/// Built once at proxy startup from `EndpointRule` definitions. Holds
323/// compiled `globset::GlobMatcher`s so the hot path does a regex match,
324/// not a glob compile.
325pub struct CompiledEndpointRules {
326    rules: Vec<CompiledRule>,
327}
328
329struct CompiledRule {
330    method: String,
331    matcher: globset::GlobMatcher,
332}
333
334impl CompiledEndpointRules {
335    /// Compile endpoint rules into matchers. Invalid glob patterns are
336    /// rejected at startup with an error, not silently ignored at runtime.
337    pub fn compile(rules: &[EndpointRule]) -> Result<Self, String> {
338        let mut compiled = Vec::with_capacity(rules.len());
339        for rule in rules {
340            let glob = Glob::new(&rule.path)
341                .map_err(|e| format!("invalid endpoint path pattern '{}': {}", rule.path, e))?;
342            compiled.push(CompiledRule {
343                method: rule.method.clone(),
344                matcher: glob.compile_matcher(),
345            });
346        }
347        Ok(Self { rules: compiled })
348    }
349
350    /// `true` if no endpoint rules are defined (allow-all).
351    #[must_use]
352    pub fn is_empty(&self) -> bool {
353        self.rules.is_empty()
354    }
355
356    /// `true` if method+path matches a rule, or if no rules are defined.
357    #[must_use]
358    pub fn is_allowed(&self, method: &str, path: &str) -> bool {
359        if self.rules.is_empty() {
360            return true;
361        }
362        let normalized = normalize_path(path);
363        self.rules.iter().any(|r| {
364            (r.method == "*" || r.method.eq_ignore_ascii_case(method))
365                && r.matcher.is_match(&normalized)
366        })
367    }
368}
369
370impl std::fmt::Debug for CompiledEndpointRules {
371    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372        f.debug_struct("CompiledEndpointRules")
373            .field("count", &self.rules.len())
374            .finish()
375    }
376}
377
378/// Check if any endpoint rule permits the given method+path.
379/// Returns `true` if rules is empty (allow-all, backward compatible).
380///
381/// Test convenience only — compiles globs on each call. Production code
382/// should use `CompiledEndpointRules::is_allowed()` instead.
383#[cfg(test)]
384fn endpoint_allowed(rules: &[EndpointRule], method: &str, path: &str) -> bool {
385    if rules.is_empty() {
386        return true;
387    }
388    let normalized = normalize_path(path);
389    rules.iter().any(|r| {
390        (r.method == "*" || r.method.eq_ignore_ascii_case(method))
391            && Glob::new(&r.path)
392                .ok()
393                .map(|g| g.compile_matcher())
394                .is_some_and(|m| m.is_match(&normalized))
395    })
396}
397
398/// Normalize a URL path for matching: percent-decode, strip query string,
399/// collapse double slashes, strip trailing slash (but preserve root "/").
400///
401/// Percent-decoding prevents bypass via encoded characters (e.g.,
402/// `/api/%70rojects` evading a rule for `/api/projects/*`).
403fn normalize_path(path: &str) -> String {
404    // Strip query string
405    let path = path.split('?').next().unwrap_or(path);
406
407    // Percent-decode to prevent bypass via encoded segments.
408    // Use decode_binary + from_utf8_lossy so invalid UTF-8 sequences
409    // (e.g., %FF) become U+FFFD instead of falling back to the raw path.
410    let binary = urlencoding::decode_binary(path.as_bytes());
411    let decoded = String::from_utf8_lossy(&binary);
412
413    // Collapse double slashes by splitting on '/' and filtering empties,
414    // then rejoin. This also strips trailing slash.
415    let segments: Vec<&str> = decoded.split('/').filter(|s| !s.is_empty()).collect();
416    if segments.is_empty() {
417        "/".to_string()
418    } else {
419        format!("/{}", segments.join("/"))
420    }
421}
422
423fn default_inject_header() -> String {
424    "Authorization".to_string()
425}
426
427/// Template for the header value before `{}` is replaced by the secret.
428///
429/// Set in config → use that string as-is. Omitted → `Bearer {}` for an `Authorization` header (case-insensitive), `{}` for any other header.
430#[must_use]
431pub fn resolved_credential_format(inject_header: &str, credential_format: Option<&str>) -> String {
432    match credential_format {
433        Some(fmt) => fmt.to_string(),
434        None => {
435            if inject_header.eq_ignore_ascii_case("Authorization") {
436                "Bearer {}".to_string()
437            } else {
438                "{}".to_string()
439            }
440        }
441    }
442}
443
444/// Configuration for an external (enterprise) proxy.
445#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct ExternalProxyConfig {
447    /// Proxy address (e.g., "squid.corp.internal:3128")
448    pub address: String,
449
450    /// Optional authentication for the external proxy.
451    pub auth: Option<ExternalProxyAuth>,
452
453    /// Hosts to bypass the external proxy and route directly.
454    /// Supports exact hostnames and `*.` wildcard suffixes (case-insensitive).
455    /// Empty = all traffic goes through the external proxy.
456    #[serde(default)]
457    pub bypass_hosts: Vec<String>,
458}
459
460/// Authentication for an external proxy.
461#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct ExternalProxyAuth {
463    /// Keystore account name for proxy credentials.
464    pub keyring_account: String,
465
466    /// Authentication scheme (only "basic" supported).
467    #[serde(default = "default_auth_scheme")]
468    pub scheme: String,
469}
470
471fn default_auth_scheme() -> String {
472    "basic".to_string()
473}
474
475/// OAuth2 client_credentials configuration for automatic token exchange.
476///
477/// When configured on a route, the proxy handles the token lifecycle:
478/// 1. Exchanges client_id + client_secret for an access_token at startup
479/// 2. Caches the token with TTL from the `expires_in` response
480/// 3. Refreshes automatically before expiry (30s buffer)
481/// 4. Injects the access_token as `Authorization: Bearer <token>`
482///
483/// The agent never sees client_id or client_secret — only a phantom token.
484#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
485pub struct OAuth2Config {
486    /// Token endpoint URL (e.g., "https://auth.example.com/oauth/token")
487    pub token_url: String,
488    /// Client ID — plain value or credential reference (env://, file://, op://)
489    pub client_id: String,
490    /// Client secret — credential reference (env://, file://, op://)
491    pub client_secret: String,
492    /// OAuth2 scopes (space-separated). Empty = no scope parameter sent.
493    #[serde(default)]
494    pub scope: String,
495}
496
497#[cfg(test)]
498#[allow(clippy::unwrap_used)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn test_default_config() {
504        let config = ProxyConfig::default();
505        assert_eq!(config.bind_addr, IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
506        assert_eq!(config.bind_port, 0);
507        assert!(config.allowed_hosts.is_empty());
508        assert!(config.routes.is_empty());
509        assert!(config.external_proxy.is_none());
510    }
511
512    #[test]
513    fn test_config_serialization() {
514        let config = ProxyConfig {
515            allowed_hosts: vec!["api.openai.com".to_string()],
516            ..Default::default()
517        };
518        let json = serde_json::to_string(&config).unwrap();
519        let deserialized: ProxyConfig = serde_json::from_str(&json).unwrap();
520        assert_eq!(deserialized.allowed_hosts, vec!["api.openai.com"]);
521    }
522
523    #[test]
524    fn test_external_proxy_config_with_bypass_hosts() {
525        let config = ProxyConfig {
526            external_proxy: Some(ExternalProxyConfig {
527                address: "squid.corp:3128".to_string(),
528                auth: None,
529                bypass_hosts: vec!["internal.corp".to_string(), "*.private.net".to_string()],
530            }),
531            ..Default::default()
532        };
533        let json = serde_json::to_string(&config).unwrap();
534        let deserialized: ProxyConfig = serde_json::from_str(&json).unwrap();
535        let ext = deserialized.external_proxy.unwrap();
536        assert_eq!(ext.address, "squid.corp:3128");
537        assert_eq!(ext.bypass_hosts.len(), 2);
538        assert_eq!(ext.bypass_hosts[0], "internal.corp");
539        assert_eq!(ext.bypass_hosts[1], "*.private.net");
540    }
541
542    #[test]
543    fn test_external_proxy_config_bypass_hosts_default_empty() {
544        let json = r#"{"address": "proxy:3128", "auth": null}"#;
545        let ext: ExternalProxyConfig = serde_json::from_str(json).unwrap();
546        assert!(ext.bypass_hosts.is_empty());
547    }
548
549    // ========================================================================
550    // EndpointRule + path matching tests
551    // ========================================================================
552
553    #[test]
554    fn test_endpoint_allowed_empty_rules_allows_all() {
555        assert!(endpoint_allowed(&[], "GET", "/anything"));
556        assert!(endpoint_allowed(&[], "DELETE", "/admin/nuke"));
557    }
558
559    /// Helper: check a single rule against method+path via endpoint_allowed.
560    fn check(rule: &EndpointRule, method: &str, path: &str) -> bool {
561        endpoint_allowed(std::slice::from_ref(rule), method, path)
562    }
563
564    #[test]
565    fn test_endpoint_rule_exact_path() {
566        let rule = EndpointRule {
567            method: "GET".to_string(),
568            path: "/v1/chat/completions".to_string(),
569        };
570        assert!(check(&rule, "GET", "/v1/chat/completions"));
571        assert!(!check(&rule, "GET", "/v1/chat"));
572        assert!(!check(&rule, "GET", "/v1/chat/completions/extra"));
573    }
574
575    #[test]
576    fn test_endpoint_rule_method_case_insensitive() {
577        let rule = EndpointRule {
578            method: "get".to_string(),
579            path: "/api".to_string(),
580        };
581        assert!(check(&rule, "GET", "/api"));
582        assert!(check(&rule, "Get", "/api"));
583    }
584
585    #[test]
586    fn test_endpoint_rule_method_wildcard() {
587        let rule = EndpointRule {
588            method: "*".to_string(),
589            path: "/api/resource".to_string(),
590        };
591        assert!(check(&rule, "GET", "/api/resource"));
592        assert!(check(&rule, "DELETE", "/api/resource"));
593        assert!(check(&rule, "POST", "/api/resource"));
594    }
595
596    #[test]
597    fn test_endpoint_rule_method_mismatch() {
598        let rule = EndpointRule {
599            method: "GET".to_string(),
600            path: "/api/resource".to_string(),
601        };
602        assert!(!check(&rule, "POST", "/api/resource"));
603        assert!(!check(&rule, "DELETE", "/api/resource"));
604    }
605
606    #[test]
607    fn test_endpoint_rule_single_wildcard() {
608        let rule = EndpointRule {
609            method: "GET".to_string(),
610            path: "/api/v4/projects/*/merge_requests".to_string(),
611        };
612        assert!(check(&rule, "GET", "/api/v4/projects/123/merge_requests"));
613        assert!(check(
614            &rule,
615            "GET",
616            "/api/v4/projects/my-proj/merge_requests"
617        ));
618        assert!(!check(&rule, "GET", "/api/v4/projects/merge_requests"));
619    }
620
621    #[test]
622    fn test_endpoint_rule_double_wildcard() {
623        let rule = EndpointRule {
624            method: "GET".to_string(),
625            path: "/api/v4/projects/**".to_string(),
626        };
627        assert!(check(&rule, "GET", "/api/v4/projects/123"));
628        assert!(check(&rule, "GET", "/api/v4/projects/123/merge_requests"));
629        assert!(check(&rule, "GET", "/api/v4/projects/a/b/c/d"));
630        assert!(!check(&rule, "GET", "/api/v4/other"));
631    }
632
633    #[test]
634    fn test_endpoint_rule_double_wildcard_middle() {
635        let rule = EndpointRule {
636            method: "*".to_string(),
637            path: "/api/**/notes".to_string(),
638        };
639        assert!(check(&rule, "GET", "/api/notes"));
640        assert!(check(&rule, "POST", "/api/projects/123/notes"));
641        assert!(check(&rule, "GET", "/api/a/b/c/notes"));
642        assert!(!check(&rule, "GET", "/api/a/b/c/comments"));
643    }
644
645    #[test]
646    fn test_endpoint_rule_strips_query_string() {
647        let rule = EndpointRule {
648            method: "GET".to_string(),
649            path: "/api/data".to_string(),
650        };
651        assert!(check(&rule, "GET", "/api/data?page=1&limit=10"));
652    }
653
654    #[test]
655    fn test_endpoint_rule_trailing_slash_normalized() {
656        let rule = EndpointRule {
657            method: "GET".to_string(),
658            path: "/api/data".to_string(),
659        };
660        assert!(check(&rule, "GET", "/api/data/"));
661        assert!(check(&rule, "GET", "/api/data"));
662    }
663
664    #[test]
665    fn test_endpoint_rule_double_slash_normalized() {
666        let rule = EndpointRule {
667            method: "GET".to_string(),
668            path: "/api/data".to_string(),
669        };
670        assert!(check(&rule, "GET", "/api//data"));
671    }
672
673    #[test]
674    fn test_endpoint_rule_root_path() {
675        let rule = EndpointRule {
676            method: "GET".to_string(),
677            path: "/".to_string(),
678        };
679        assert!(check(&rule, "GET", "/"));
680        assert!(!check(&rule, "GET", "/anything"));
681    }
682
683    #[test]
684    fn test_compiled_endpoint_rules_hot_path() {
685        let rules = vec![
686            EndpointRule {
687                method: "GET".to_string(),
688                path: "/repos/*/issues".to_string(),
689            },
690            EndpointRule {
691                method: "POST".to_string(),
692                path: "/repos/*/issues/*/comments".to_string(),
693            },
694        ];
695        let compiled = CompiledEndpointRules::compile(&rules).unwrap();
696        assert!(compiled.is_allowed("GET", "/repos/myrepo/issues"));
697        assert!(compiled.is_allowed("POST", "/repos/myrepo/issues/42/comments"));
698        assert!(!compiled.is_allowed("DELETE", "/repos/myrepo"));
699        assert!(!compiled.is_allowed("GET", "/repos/myrepo/pulls"));
700    }
701
702    #[test]
703    fn test_compiled_endpoint_rules_empty_allows_all() {
704        let compiled = CompiledEndpointRules::compile(&[]).unwrap();
705        assert!(compiled.is_allowed("DELETE", "/admin/nuke"));
706    }
707
708    #[test]
709    fn test_compiled_endpoint_rules_invalid_pattern_rejected() {
710        let rules = vec![EndpointRule {
711            method: "GET".to_string(),
712            path: "/api/[invalid".to_string(),
713        }];
714        assert!(CompiledEndpointRules::compile(&rules).is_err());
715    }
716
717    #[test]
718    fn test_endpoint_allowed_multiple_rules() {
719        let rules = vec![
720            EndpointRule {
721                method: "GET".to_string(),
722                path: "/repos/*/issues".to_string(),
723            },
724            EndpointRule {
725                method: "POST".to_string(),
726                path: "/repos/*/issues/*/comments".to_string(),
727            },
728        ];
729        assert!(endpoint_allowed(&rules, "GET", "/repos/myrepo/issues"));
730        assert!(endpoint_allowed(
731            &rules,
732            "POST",
733            "/repos/myrepo/issues/42/comments"
734        ));
735        assert!(!endpoint_allowed(&rules, "DELETE", "/repos/myrepo"));
736        assert!(!endpoint_allowed(&rules, "GET", "/repos/myrepo/pulls"));
737    }
738
739    #[test]
740    fn test_endpoint_rule_serde_default() {
741        let json = r#"{
742            "prefix": "test",
743            "upstream": "https://example.com"
744        }"#;
745        let route: RouteConfig = serde_json::from_str(json).unwrap();
746        assert!(route.endpoint_rules.is_empty());
747        assert!(route.tls_ca.is_none());
748    }
749
750    #[test]
751    fn test_tls_ca_serde_roundtrip() {
752        let json = r#"{
753            "prefix": "k8s",
754            "upstream": "https://kubernetes.local:6443",
755            "tls_ca": "/run/secrets/k8s-ca.crt"
756        }"#;
757        let route: RouteConfig = serde_json::from_str(json).unwrap();
758        assert_eq!(route.tls_ca.as_deref(), Some("/run/secrets/k8s-ca.crt"));
759
760        let serialized = serde_json::to_string(&route).unwrap();
761        let deserialized: RouteConfig = serde_json::from_str(&serialized).unwrap();
762        assert_eq!(
763            deserialized.tls_ca.as_deref(),
764            Some("/run/secrets/k8s-ca.crt")
765        );
766    }
767
768    #[test]
769    fn test_endpoint_rule_percent_encoded_path_decoded() {
770        // Security: percent-encoded segments must not bypass rules.
771        // e.g., /api/v4/%70rojects should match a rule for /api/v4/projects/*
772        let rule = EndpointRule {
773            method: "GET".to_string(),
774            path: "/api/v4/projects/*/issues".to_string(),
775        };
776        assert!(check(&rule, "GET", "/api/v4/%70rojects/123/issues"));
777        assert!(check(&rule, "GET", "/api/v4/pro%6Aects/123/issues"));
778    }
779
780    #[test]
781    fn test_endpoint_rule_percent_encoded_full_segment() {
782        let rule = EndpointRule {
783            method: "POST".to_string(),
784            path: "/api/data".to_string(),
785        };
786        // %64%61%74%61 = "data"
787        assert!(check(&rule, "POST", "/api/%64%61%74%61"));
788    }
789
790    #[test]
791    fn test_compiled_endpoint_rules_percent_encoded() {
792        let rules = vec![EndpointRule {
793            method: "GET".to_string(),
794            path: "/repos/*/issues".to_string(),
795        }];
796        let compiled = CompiledEndpointRules::compile(&rules).unwrap();
797        // %69ssues = "issues"
798        assert!(compiled.is_allowed("GET", "/repos/myrepo/%69ssues"));
799        assert!(!compiled.is_allowed("GET", "/repos/myrepo/%70ulls"));
800    }
801
802    #[test]
803    fn test_endpoint_rule_percent_encoded_invalid_utf8() {
804        // Security: invalid UTF-8 percent sequences must not fall back to
805        // the raw path (which could bypass rules). Lossy decoding replaces
806        // invalid bytes with U+FFFD, so the path won't match real segments.
807        let rule = EndpointRule {
808            method: "GET".to_string(),
809            path: "/api/projects".to_string(),
810        };
811        // %FF is not valid UTF-8 — must not match "/api/projects"
812        assert!(!check(&rule, "GET", "/api/%FFprojects"));
813    }
814
815    #[test]
816    fn test_endpoint_rule_serde_roundtrip() {
817        let rule = EndpointRule {
818            method: "GET".to_string(),
819            path: "/api/*/data".to_string(),
820        };
821        let json = serde_json::to_string(&rule).unwrap();
822        let deserialized: EndpointRule = serde_json::from_str(&json).unwrap();
823        assert_eq!(deserialized.method, "GET");
824        assert_eq!(deserialized.path, "/api/*/data");
825    }
826
827    // ========================================================================
828    // OAuth2Config tests
829    // ========================================================================
830
831    #[test]
832    fn test_oauth2_config_deserialization() {
833        let json = r#"{
834            "token_url": "https://auth.example.com/oauth/token",
835            "client_id": "my-client",
836            "client_secret": "env://CLIENT_SECRET",
837            "scope": "read write"
838        }"#;
839        let config: OAuth2Config = serde_json::from_str(json).unwrap();
840        assert_eq!(config.token_url, "https://auth.example.com/oauth/token");
841        assert_eq!(config.client_id, "my-client");
842        assert_eq!(config.client_secret, "env://CLIENT_SECRET");
843        assert_eq!(config.scope, "read write");
844    }
845
846    #[test]
847    fn test_oauth2_config_default_scope() {
848        let json = r#"{
849            "token_url": "https://auth.example.com/oauth/token",
850            "client_id": "my-client",
851            "client_secret": "env://SECRET"
852        }"#;
853        let config: OAuth2Config = serde_json::from_str(json).unwrap();
854        assert_eq!(config.scope, "");
855    }
856
857    #[test]
858    fn test_route_config_with_oauth2() {
859        let json = r#"{
860            "prefix": "/my-api",
861            "upstream": "https://api.example.com",
862            "oauth2": {
863                "token_url": "https://auth.example.com/oauth/token",
864                "client_id": "agent-1",
865                "client_secret": "env://CLIENT_SECRET",
866                "scope": "api.read"
867            }
868        }"#;
869        let route: RouteConfig = serde_json::from_str(json).unwrap();
870        assert!(route.oauth2.is_some());
871        assert!(route.credential_key.is_none());
872        let oauth2 = route.oauth2.unwrap();
873        assert_eq!(oauth2.token_url, "https://auth.example.com/oauth/token");
874    }
875
876    #[test]
877    fn test_route_config_without_oauth2() {
878        let json = r#"{
879            "prefix": "/openai",
880            "upstream": "https://api.openai.com",
881            "credential_key": "openai"
882        }"#;
883        let route: RouteConfig = serde_json::from_str(json).unwrap();
884        assert!(route.oauth2.is_none());
885        assert!(route.credential_key.is_some());
886    }
887
888    #[test]
889    fn test_route_config_credential_format_omitted_is_none() {
890        let json = r#"{
891            "prefix": "anthropic",
892            "upstream": "https://api.anthropic.com",
893            "credential_key": "env://ANTHROPIC_API_KEY",
894            "inject_header": "x-api-key"
895        }"#;
896        let route: RouteConfig = serde_json::from_str(json).unwrap();
897        assert!(route.credential_format.is_none());
898        assert_eq!(
899            resolved_credential_format(&route.inject_header, route.credential_format.as_deref()),
900            "{}"
901        );
902    }
903
904    #[test]
905    fn test_route_config_explicit_bearer_on_custom_header_preserved() {
906        let json = r#"{
907            "prefix": "litellm",
908            "upstream": "https://litellm",
909            "credential_key": "env://LITELLM_TOKEN",
910            "inject_header": "x-litellm-api-key",
911            "credential_format": "Bearer {}"
912        }"#;
913        let route: RouteConfig = serde_json::from_str(json).unwrap();
914        assert_eq!(route.credential_format.as_deref(), Some("Bearer {}"));
915        assert_eq!(
916            resolved_credential_format(&route.inject_header, route.credential_format.as_deref()),
917            "Bearer {}"
918        );
919    }
920
921    #[test]
922    fn test_resolved_credential_format_authorization_case_insensitive() {
923        for header in ["authorization", "AUTHORIZATION", "Authorization"] {
924            assert_eq!(
925                resolved_credential_format(header, None),
926                "Bearer {}",
927                "omitted format: Authorization header name is matched case-insensitively for Bearer default"
928            );
929        }
930    }
931}