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