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    /// Check if the given method+path is allowed.
298    /// Returns `true` if no rules were compiled (allow-all, backward compatible).
299    #[must_use]
300    pub fn is_allowed(&self, method: &str, path: &str) -> bool {
301        if self.rules.is_empty() {
302            return true;
303        }
304        let normalized = normalize_path(path);
305        self.rules.iter().any(|r| {
306            (r.method == "*" || r.method.eq_ignore_ascii_case(method))
307                && r.matcher.is_match(&normalized)
308        })
309    }
310}
311
312impl std::fmt::Debug for CompiledEndpointRules {
313    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        f.debug_struct("CompiledEndpointRules")
315            .field("count", &self.rules.len())
316            .finish()
317    }
318}
319
320/// Check if any endpoint rule permits the given method+path.
321/// Returns `true` if rules is empty (allow-all, backward compatible).
322///
323/// Test convenience only — compiles globs on each call. Production code
324/// should use `CompiledEndpointRules::is_allowed()` instead.
325#[cfg(test)]
326fn endpoint_allowed(rules: &[EndpointRule], method: &str, path: &str) -> bool {
327    if rules.is_empty() {
328        return true;
329    }
330    let normalized = normalize_path(path);
331    rules.iter().any(|r| {
332        (r.method == "*" || r.method.eq_ignore_ascii_case(method))
333            && Glob::new(&r.path)
334                .ok()
335                .map(|g| g.compile_matcher())
336                .is_some_and(|m| m.is_match(&normalized))
337    })
338}
339
340/// Normalize a URL path for matching: percent-decode, strip query string,
341/// collapse double slashes, strip trailing slash (but preserve root "/").
342///
343/// Percent-decoding prevents bypass via encoded characters (e.g.,
344/// `/api/%70rojects` evading a rule for `/api/projects/*`).
345fn normalize_path(path: &str) -> String {
346    // Strip query string
347    let path = path.split('?').next().unwrap_or(path);
348
349    // Percent-decode to prevent bypass via encoded segments.
350    // Use decode_binary + from_utf8_lossy so invalid UTF-8 sequences
351    // (e.g., %FF) become U+FFFD instead of falling back to the raw path.
352    let binary = urlencoding::decode_binary(path.as_bytes());
353    let decoded = String::from_utf8_lossy(&binary);
354
355    // Collapse double slashes by splitting on '/' and filtering empties,
356    // then rejoin. This also strips trailing slash.
357    let segments: Vec<&str> = decoded.split('/').filter(|s| !s.is_empty()).collect();
358    if segments.is_empty() {
359        "/".to_string()
360    } else {
361        format!("/{}", segments.join("/"))
362    }
363}
364
365fn default_inject_header() -> String {
366    "Authorization".to_string()
367}
368
369fn default_credential_format() -> String {
370    "Bearer {}".to_string()
371}
372
373/// Configuration for an external (enterprise) proxy.
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct ExternalProxyConfig {
376    /// Proxy address (e.g., "squid.corp.internal:3128")
377    pub address: String,
378
379    /// Optional authentication for the external proxy.
380    pub auth: Option<ExternalProxyAuth>,
381
382    /// Hosts to bypass the external proxy and route directly.
383    /// Supports exact hostnames and `*.` wildcard suffixes (case-insensitive).
384    /// Empty = all traffic goes through the external proxy.
385    #[serde(default)]
386    pub bypass_hosts: Vec<String>,
387}
388
389/// Authentication for an external proxy.
390#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct ExternalProxyAuth {
392    /// Keystore account name for proxy credentials.
393    pub keyring_account: String,
394
395    /// Authentication scheme (only "basic" supported).
396    #[serde(default = "default_auth_scheme")]
397    pub scheme: String,
398}
399
400fn default_auth_scheme() -> String {
401    "basic".to_string()
402}
403
404/// OAuth2 client_credentials configuration for automatic token exchange.
405///
406/// When configured on a route, the proxy handles the token lifecycle:
407/// 1. Exchanges client_id + client_secret for an access_token at startup
408/// 2. Caches the token with TTL from the `expires_in` response
409/// 3. Refreshes automatically before expiry (30s buffer)
410/// 4. Injects the access_token as `Authorization: Bearer <token>`
411///
412/// The agent never sees client_id or client_secret — only a phantom token.
413#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
414pub struct OAuth2Config {
415    /// Token endpoint URL (e.g., "https://auth.example.com/oauth/token")
416    pub token_url: String,
417    /// Client ID — plain value or credential reference (env://, file://, op://)
418    pub client_id: String,
419    /// Client secret — credential reference (env://, file://, op://)
420    pub client_secret: String,
421    /// OAuth2 scopes (space-separated). Empty = no scope parameter sent.
422    #[serde(default)]
423    pub scope: String,
424}
425
426#[cfg(test)]
427#[allow(clippy::unwrap_used)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn test_default_config() {
433        let config = ProxyConfig::default();
434        assert_eq!(config.bind_addr, IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
435        assert_eq!(config.bind_port, 0);
436        assert!(config.allowed_hosts.is_empty());
437        assert!(config.routes.is_empty());
438        assert!(config.external_proxy.is_none());
439    }
440
441    #[test]
442    fn test_config_serialization() {
443        let config = ProxyConfig {
444            allowed_hosts: vec!["api.openai.com".to_string()],
445            ..Default::default()
446        };
447        let json = serde_json::to_string(&config).unwrap();
448        let deserialized: ProxyConfig = serde_json::from_str(&json).unwrap();
449        assert_eq!(deserialized.allowed_hosts, vec!["api.openai.com"]);
450    }
451
452    #[test]
453    fn test_external_proxy_config_with_bypass_hosts() {
454        let config = ProxyConfig {
455            external_proxy: Some(ExternalProxyConfig {
456                address: "squid.corp:3128".to_string(),
457                auth: None,
458                bypass_hosts: vec!["internal.corp".to_string(), "*.private.net".to_string()],
459            }),
460            ..Default::default()
461        };
462        let json = serde_json::to_string(&config).unwrap();
463        let deserialized: ProxyConfig = serde_json::from_str(&json).unwrap();
464        let ext = deserialized.external_proxy.unwrap();
465        assert_eq!(ext.address, "squid.corp:3128");
466        assert_eq!(ext.bypass_hosts.len(), 2);
467        assert_eq!(ext.bypass_hosts[0], "internal.corp");
468        assert_eq!(ext.bypass_hosts[1], "*.private.net");
469    }
470
471    #[test]
472    fn test_external_proxy_config_bypass_hosts_default_empty() {
473        let json = r#"{"address": "proxy:3128", "auth": null}"#;
474        let ext: ExternalProxyConfig = serde_json::from_str(json).unwrap();
475        assert!(ext.bypass_hosts.is_empty());
476    }
477
478    // ========================================================================
479    // EndpointRule + path matching tests
480    // ========================================================================
481
482    #[test]
483    fn test_endpoint_allowed_empty_rules_allows_all() {
484        assert!(endpoint_allowed(&[], "GET", "/anything"));
485        assert!(endpoint_allowed(&[], "DELETE", "/admin/nuke"));
486    }
487
488    /// Helper: check a single rule against method+path via endpoint_allowed.
489    fn check(rule: &EndpointRule, method: &str, path: &str) -> bool {
490        endpoint_allowed(std::slice::from_ref(rule), method, path)
491    }
492
493    #[test]
494    fn test_endpoint_rule_exact_path() {
495        let rule = EndpointRule {
496            method: "GET".to_string(),
497            path: "/v1/chat/completions".to_string(),
498        };
499        assert!(check(&rule, "GET", "/v1/chat/completions"));
500        assert!(!check(&rule, "GET", "/v1/chat"));
501        assert!(!check(&rule, "GET", "/v1/chat/completions/extra"));
502    }
503
504    #[test]
505    fn test_endpoint_rule_method_case_insensitive() {
506        let rule = EndpointRule {
507            method: "get".to_string(),
508            path: "/api".to_string(),
509        };
510        assert!(check(&rule, "GET", "/api"));
511        assert!(check(&rule, "Get", "/api"));
512    }
513
514    #[test]
515    fn test_endpoint_rule_method_wildcard() {
516        let rule = EndpointRule {
517            method: "*".to_string(),
518            path: "/api/resource".to_string(),
519        };
520        assert!(check(&rule, "GET", "/api/resource"));
521        assert!(check(&rule, "DELETE", "/api/resource"));
522        assert!(check(&rule, "POST", "/api/resource"));
523    }
524
525    #[test]
526    fn test_endpoint_rule_method_mismatch() {
527        let rule = EndpointRule {
528            method: "GET".to_string(),
529            path: "/api/resource".to_string(),
530        };
531        assert!(!check(&rule, "POST", "/api/resource"));
532        assert!(!check(&rule, "DELETE", "/api/resource"));
533    }
534
535    #[test]
536    fn test_endpoint_rule_single_wildcard() {
537        let rule = EndpointRule {
538            method: "GET".to_string(),
539            path: "/api/v4/projects/*/merge_requests".to_string(),
540        };
541        assert!(check(&rule, "GET", "/api/v4/projects/123/merge_requests"));
542        assert!(check(
543            &rule,
544            "GET",
545            "/api/v4/projects/my-proj/merge_requests"
546        ));
547        assert!(!check(&rule, "GET", "/api/v4/projects/merge_requests"));
548    }
549
550    #[test]
551    fn test_endpoint_rule_double_wildcard() {
552        let rule = EndpointRule {
553            method: "GET".to_string(),
554            path: "/api/v4/projects/**".to_string(),
555        };
556        assert!(check(&rule, "GET", "/api/v4/projects/123"));
557        assert!(check(&rule, "GET", "/api/v4/projects/123/merge_requests"));
558        assert!(check(&rule, "GET", "/api/v4/projects/a/b/c/d"));
559        assert!(!check(&rule, "GET", "/api/v4/other"));
560    }
561
562    #[test]
563    fn test_endpoint_rule_double_wildcard_middle() {
564        let rule = EndpointRule {
565            method: "*".to_string(),
566            path: "/api/**/notes".to_string(),
567        };
568        assert!(check(&rule, "GET", "/api/notes"));
569        assert!(check(&rule, "POST", "/api/projects/123/notes"));
570        assert!(check(&rule, "GET", "/api/a/b/c/notes"));
571        assert!(!check(&rule, "GET", "/api/a/b/c/comments"));
572    }
573
574    #[test]
575    fn test_endpoint_rule_strips_query_string() {
576        let rule = EndpointRule {
577            method: "GET".to_string(),
578            path: "/api/data".to_string(),
579        };
580        assert!(check(&rule, "GET", "/api/data?page=1&limit=10"));
581    }
582
583    #[test]
584    fn test_endpoint_rule_trailing_slash_normalized() {
585        let rule = EndpointRule {
586            method: "GET".to_string(),
587            path: "/api/data".to_string(),
588        };
589        assert!(check(&rule, "GET", "/api/data/"));
590        assert!(check(&rule, "GET", "/api/data"));
591    }
592
593    #[test]
594    fn test_endpoint_rule_double_slash_normalized() {
595        let rule = EndpointRule {
596            method: "GET".to_string(),
597            path: "/api/data".to_string(),
598        };
599        assert!(check(&rule, "GET", "/api//data"));
600    }
601
602    #[test]
603    fn test_endpoint_rule_root_path() {
604        let rule = EndpointRule {
605            method: "GET".to_string(),
606            path: "/".to_string(),
607        };
608        assert!(check(&rule, "GET", "/"));
609        assert!(!check(&rule, "GET", "/anything"));
610    }
611
612    #[test]
613    fn test_compiled_endpoint_rules_hot_path() {
614        let rules = vec![
615            EndpointRule {
616                method: "GET".to_string(),
617                path: "/repos/*/issues".to_string(),
618            },
619            EndpointRule {
620                method: "POST".to_string(),
621                path: "/repos/*/issues/*/comments".to_string(),
622            },
623        ];
624        let compiled = CompiledEndpointRules::compile(&rules).unwrap();
625        assert!(compiled.is_allowed("GET", "/repos/myrepo/issues"));
626        assert!(compiled.is_allowed("POST", "/repos/myrepo/issues/42/comments"));
627        assert!(!compiled.is_allowed("DELETE", "/repos/myrepo"));
628        assert!(!compiled.is_allowed("GET", "/repos/myrepo/pulls"));
629    }
630
631    #[test]
632    fn test_compiled_endpoint_rules_empty_allows_all() {
633        let compiled = CompiledEndpointRules::compile(&[]).unwrap();
634        assert!(compiled.is_allowed("DELETE", "/admin/nuke"));
635    }
636
637    #[test]
638    fn test_compiled_endpoint_rules_invalid_pattern_rejected() {
639        let rules = vec![EndpointRule {
640            method: "GET".to_string(),
641            path: "/api/[invalid".to_string(),
642        }];
643        assert!(CompiledEndpointRules::compile(&rules).is_err());
644    }
645
646    #[test]
647    fn test_endpoint_allowed_multiple_rules() {
648        let rules = vec![
649            EndpointRule {
650                method: "GET".to_string(),
651                path: "/repos/*/issues".to_string(),
652            },
653            EndpointRule {
654                method: "POST".to_string(),
655                path: "/repos/*/issues/*/comments".to_string(),
656            },
657        ];
658        assert!(endpoint_allowed(&rules, "GET", "/repos/myrepo/issues"));
659        assert!(endpoint_allowed(
660            &rules,
661            "POST",
662            "/repos/myrepo/issues/42/comments"
663        ));
664        assert!(!endpoint_allowed(&rules, "DELETE", "/repos/myrepo"));
665        assert!(!endpoint_allowed(&rules, "GET", "/repos/myrepo/pulls"));
666    }
667
668    #[test]
669    fn test_endpoint_rule_serde_default() {
670        let json = r#"{
671            "prefix": "test",
672            "upstream": "https://example.com"
673        }"#;
674        let route: RouteConfig = serde_json::from_str(json).unwrap();
675        assert!(route.endpoint_rules.is_empty());
676        assert!(route.tls_ca.is_none());
677    }
678
679    #[test]
680    fn test_tls_ca_serde_roundtrip() {
681        let json = r#"{
682            "prefix": "k8s",
683            "upstream": "https://kubernetes.local:6443",
684            "tls_ca": "/run/secrets/k8s-ca.crt"
685        }"#;
686        let route: RouteConfig = serde_json::from_str(json).unwrap();
687        assert_eq!(route.tls_ca.as_deref(), Some("/run/secrets/k8s-ca.crt"));
688
689        let serialized = serde_json::to_string(&route).unwrap();
690        let deserialized: RouteConfig = serde_json::from_str(&serialized).unwrap();
691        assert_eq!(
692            deserialized.tls_ca.as_deref(),
693            Some("/run/secrets/k8s-ca.crt")
694        );
695    }
696
697    #[test]
698    fn test_endpoint_rule_percent_encoded_path_decoded() {
699        // Security: percent-encoded segments must not bypass rules.
700        // e.g., /api/v4/%70rojects should match a rule for /api/v4/projects/*
701        let rule = EndpointRule {
702            method: "GET".to_string(),
703            path: "/api/v4/projects/*/issues".to_string(),
704        };
705        assert!(check(&rule, "GET", "/api/v4/%70rojects/123/issues"));
706        assert!(check(&rule, "GET", "/api/v4/pro%6Aects/123/issues"));
707    }
708
709    #[test]
710    fn test_endpoint_rule_percent_encoded_full_segment() {
711        let rule = EndpointRule {
712            method: "POST".to_string(),
713            path: "/api/data".to_string(),
714        };
715        // %64%61%74%61 = "data"
716        assert!(check(&rule, "POST", "/api/%64%61%74%61"));
717    }
718
719    #[test]
720    fn test_compiled_endpoint_rules_percent_encoded() {
721        let rules = vec![EndpointRule {
722            method: "GET".to_string(),
723            path: "/repos/*/issues".to_string(),
724        }];
725        let compiled = CompiledEndpointRules::compile(&rules).unwrap();
726        // %69ssues = "issues"
727        assert!(compiled.is_allowed("GET", "/repos/myrepo/%69ssues"));
728        assert!(!compiled.is_allowed("GET", "/repos/myrepo/%70ulls"));
729    }
730
731    #[test]
732    fn test_endpoint_rule_percent_encoded_invalid_utf8() {
733        // Security: invalid UTF-8 percent sequences must not fall back to
734        // the raw path (which could bypass rules). Lossy decoding replaces
735        // invalid bytes with U+FFFD, so the path won't match real segments.
736        let rule = EndpointRule {
737            method: "GET".to_string(),
738            path: "/api/projects".to_string(),
739        };
740        // %FF is not valid UTF-8 — must not match "/api/projects"
741        assert!(!check(&rule, "GET", "/api/%FFprojects"));
742    }
743
744    #[test]
745    fn test_endpoint_rule_serde_roundtrip() {
746        let rule = EndpointRule {
747            method: "GET".to_string(),
748            path: "/api/*/data".to_string(),
749        };
750        let json = serde_json::to_string(&rule).unwrap();
751        let deserialized: EndpointRule = serde_json::from_str(&json).unwrap();
752        assert_eq!(deserialized.method, "GET");
753        assert_eq!(deserialized.path, "/api/*/data");
754    }
755
756    // ========================================================================
757    // OAuth2Config tests
758    // ========================================================================
759
760    #[test]
761    fn test_oauth2_config_deserialization() {
762        let json = r#"{
763            "token_url": "https://auth.example.com/oauth/token",
764            "client_id": "my-client",
765            "client_secret": "env://CLIENT_SECRET",
766            "scope": "read write"
767        }"#;
768        let config: OAuth2Config = serde_json::from_str(json).unwrap();
769        assert_eq!(config.token_url, "https://auth.example.com/oauth/token");
770        assert_eq!(config.client_id, "my-client");
771        assert_eq!(config.client_secret, "env://CLIENT_SECRET");
772        assert_eq!(config.scope, "read write");
773    }
774
775    #[test]
776    fn test_oauth2_config_default_scope() {
777        let json = r#"{
778            "token_url": "https://auth.example.com/oauth/token",
779            "client_id": "my-client",
780            "client_secret": "env://SECRET"
781        }"#;
782        let config: OAuth2Config = serde_json::from_str(json).unwrap();
783        assert_eq!(config.scope, "");
784    }
785
786    #[test]
787    fn test_route_config_with_oauth2() {
788        let json = r#"{
789            "prefix": "/my-api",
790            "upstream": "https://api.example.com",
791            "oauth2": {
792                "token_url": "https://auth.example.com/oauth/token",
793                "client_id": "agent-1",
794                "client_secret": "env://CLIENT_SECRET",
795                "scope": "api.read"
796            }
797        }"#;
798        let route: RouteConfig = serde_json::from_str(json).unwrap();
799        assert!(route.oauth2.is_some());
800        assert!(route.credential_key.is_none());
801        let oauth2 = route.oauth2.unwrap();
802        assert_eq!(oauth2.token_url, "https://auth.example.com/oauth/token");
803    }
804
805    #[test]
806    fn test_route_config_without_oauth2() {
807        let json = r#"{
808            "prefix": "/openai",
809            "upstream": "https://api.openai.com",
810            "credential_key": "openai"
811        }"#;
812        let route: RouteConfig = serde_json::from_str(json).unwrap();
813        assert!(route.oauth2.is_none());
814        assert!(route.credential_key.is_some());
815    }
816}