Skip to main content

nono_proxy/
config.rs

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