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