#![cfg_attr(not(test), allow(dead_code))]use crate::config::NetworkProxyConfig;use crate::policy::normalize_host;use anyhow::Context as _;use anyhow::Result;use anyhow::anyhow;use codex_utils_absolute_path::AbsolutePathBuf;use globset::GlobBuilder;use globset::GlobMatcher;use rama_http::HeaderValue;use rama_http::Request;use rama_http::header::HeaderName;use serde::Deserialize;use serde::Serialize;use std::collections::BTreeMap;use std::env;use std::fs;use std::path::Path;use url::form_urlencoded;const PATTERN_PREFIX: &str = "pattern:";const LITERAL_PREFIX: &str = "literal:";#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]#[serde(default)]pub struct MitmHookConfig { pub host: String, #[serde(rename = "match", default)] pub matcher: MitmHookMatchConfig, #[serde(default)] pub actions: MitmHookActionsConfig,}#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]#[serde(default)]pub struct MitmHookMatchConfig { pub methods: Vec<String>, pub path_prefixes: Vec<String>, pub query: BTreeMap<String, Vec<String>>, pub headers: BTreeMap<String, Vec<String>>, pub body: Option<MitmHookBodyConfig>,}#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]#[serde(default)]pub struct MitmHookActionsConfig { pub strip_request_headers: Vec<String>, pub inject_request_headers: Vec<InjectedHeaderConfig>,}#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]#[serde(default)]pub struct InjectedHeaderConfig { pub name: String, pub secret_env_var: Option<String>, pub secret_file: Option<String>, pub prefix: Option<String>,}#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]#[serde(transparent)]pub struct MitmHookBodyConfig(pub serde_json::Value);#[derive(Debug, Clone, PartialEq, Eq)]pub struct MitmHook { pub host: String, pub matcher: MitmHookMatcher, pub actions: MitmHookActions,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct MitmHookMatcher { pub methods: Vec<String>, pub path_prefixes: Vec<PathMatcher>, pub query: Vec<QueryConstraint>, pub headers: Vec<HeaderConstraint>, pub body: Option<MitmHookBodyMatcher>,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct QueryConstraint { pub name: String, pub allowed_values: Vec<ValueMatcher>,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct HeaderConstraint { pub name: HeaderName, pub allowed_values: Vec<ValueMatcher>,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct MitmHookActions { pub strip_request_headers: Vec<HeaderName>, pub inject_request_headers: Vec<ResolvedInjectedHeader>,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct ResolvedInjectedHeader { pub name: HeaderName, pub value: HeaderValue, pub source: SecretSource,}#[derive(Debug, Clone, PartialEq, Eq)]pub enum SecretSource { EnvVar(String), File(AbsolutePathBuf),}#[derive(Debug, Clone, PartialEq, Eq)]pub struct MitmHookBodyMatcher { pub raw: serde_json::Value,}#[derive(Debug, Clone, PartialEq, Eq)]pub enum PathMatcher { Prefix(String), Glob(CompiledGlobMatcher),}#[derive(Debug, Clone, PartialEq, Eq)]pub enum ValueMatcher { Exact(String), Glob(CompiledGlobMatcher),}enum MatcherPattern<'a> { Literal(&'a str), Glob(&'a str),}#[derive(Clone)]pub struct CompiledGlobMatcher { pattern: String, matcher: GlobMatcher,}impl std::fmt::Debug for CompiledGlobMatcher { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("CompiledGlobMatcher") .field("pattern", &self.pattern) .finish() }}impl PartialEq for CompiledGlobMatcher { fn eq(&self, other: &Self) -> bool { self.pattern == other.pattern }}impl Eq for CompiledGlobMatcher {}impl CompiledGlobMatcher { fn is_match(&self, candidate: &str) -> bool { self.matcher.is_match(candidate) }}pub type MitmHooksByHost = BTreeMap<String, Vec<MitmHook>>;#[derive(Debug, Clone, PartialEq, Eq)]pub enum HookEvaluation { NoHooksForHost, Matched { actions: MitmHookActions }, HookedHostNoMatch,}pub(crate) fn validate_mitm_hook_config(config: &NetworkProxyConfig) -> Result<()> { let hooks = &config.network.mitm_hooks; if hooks.is_empty() { return Ok(()); } if !config.network.mitm { return Err(anyhow!("network.mitm_hooks requires network.mitm = true")); } for (hook_index, hook) in hooks.iter().enumerate() { let host = normalize_hook_host(&hook.host) .with_context(|| format!("invalid network.mitm_hooks[{hook_index}].host"))?; let methods = normalize_methods(&hook.matcher.methods) .with_context(|| format!("invalid network.mitm_hooks[{hook_index}].match.methods"))?; if methods.is_empty() { return Err(anyhow!( "network.mitm_hooks[{hook_index}].match.methods must not be empty" )); } let path_prefixes = compile_path_matchers(&hook.matcher.path_prefixes).with_context(|| { format!("invalid network.mitm_hooks[{hook_index}].match.path_prefixes") })?; if path_prefixes.is_empty() { return Err(anyhow!( "network.mitm_hooks[{hook_index}].match.path_prefixes must not be empty" )); } if let Some(body) = hook.matcher.body.as_ref() { let _ = body; return Err(anyhow!( "network.mitm_hooks[{hook_index}].match.body is reserved for a future release and is not yet supported" )); } validate_query_constraints(&hook.matcher.query) .with_context(|| format!("invalid network.mitm_hooks[{hook_index}].match.query"))?; validate_header_constraints(&hook.matcher.headers) .with_context(|| format!("invalid network.mitm_hooks[{hook_index}].match.headers"))?; validate_strip_request_headers(&hook.actions.strip_request_headers).with_context(|| { format!("invalid network.mitm_hooks[{hook_index}].actions.strip_request_headers") })?; validate_injected_headers(&hook.actions.inject_request_headers).with_context(|| { format!("invalid network.mitm_hooks[{hook_index}].actions.inject_request_headers") })?; if host.is_empty() { return Err(anyhow!( "network.mitm_hooks[{hook_index}].host must not be empty" )); } } Ok(())}pub(crate) fn compile_mitm_hooks(config: &NetworkProxyConfig) -> Result<MitmHooksByHost> { compile_mitm_hooks_with_resolvers( config, |name| env::var(name).ok(), |path| { let value = fs::read_to_string(path.as_path()).with_context(|| { format!("failed to read secret file {}", path.as_path().display()) })?; Ok(value.trim().to_string()) }, )}pub(crate) fn evaluate_mitm_hooks( hooks_by_host: &MitmHooksByHost, host: &str, req: &Request,) -> HookEvaluation { let normalized_host = normalize_host(host); let Some(hooks) = hooks_by_host.get(&normalized_host) else { return HookEvaluation::NoHooksForHost; }; for hook in hooks { if hook_matches(hook, req) { return HookEvaluation::Matched { actions: hook.actions.clone(), }; } } HookEvaluation::HookedHostNoMatch}fn compile_mitm_hooks_with_resolvers<EnvFn, FileFn>( config: &NetworkProxyConfig, resolve_env_var: EnvFn, read_secret_file: FileFn,) -> Result<MitmHooksByHost>where EnvFn: Fn(&str) -> Option<String>, FileFn: Fn(&AbsolutePathBuf) -> Result<String>,{ validate_mitm_hook_config(config)?; let mut hooks_by_host = MitmHooksByHost::new(); for hook in &config.network.mitm_hooks { let host = normalize_hook_host(&hook.host)?; let methods = normalize_methods(&hook.matcher.methods)?; let path_prefixes = compile_path_matchers(&hook.matcher.path_prefixes)?; let query = hook .matcher .query .iter() .map(|(name, values)| { Ok(QueryConstraint { name: normalize_query_name(name)?, allowed_values: compile_value_matchers(values)?, }) }) .collect::<Result<Vec<_>>>()?; let headers = hook .matcher .headers .iter() .map(|(name, values)| { Ok(HeaderConstraint { name: parse_header_name(name)?, allowed_values: compile_value_matchers(values)?, }) }) .collect::<Result<Vec<_>>>()?; let strip_request_headers = hook .actions .strip_request_headers .iter() .map(|name| parse_header_name(name)) .collect::<Result<Vec<_>>>()?; let inject_request_headers = hook .actions .inject_request_headers .iter() .map(|header| { compile_injected_header(header, &resolve_env_var, &read_secret_file) .with_context(|| format!("failed to compile injected header {}", header.name)) }) .collect::<Result<Vec<_>>>()?; hooks_by_host .entry(host.clone()) .or_default() .push(MitmHook { host, matcher: MitmHookMatcher { methods, path_prefixes, query, headers, body: None, }, actions: MitmHookActions { strip_request_headers, inject_request_headers, }, }); } Ok(hooks_by_host)}fn compile_injected_header<EnvFn, FileFn>( header: &InjectedHeaderConfig, resolve_env_var: &EnvFn, read_secret_file: &FileFn,) -> Result<ResolvedInjectedHeader>where EnvFn: Fn(&str) -> Option<String>, FileFn: Fn(&AbsolutePathBuf) -> Result<String>,{ let name = parse_header_name(&header.name)?; let (secret, source) = match ( header.secret_env_var.as_deref(), header.secret_file.as_deref(), ) { (Some(env_var), None) => { let value = resolve_env_var(env_var) .ok_or_else(|| anyhow!("missing required environment variable {env_var}"))?; (value, SecretSource::EnvVar(env_var.to_string())) } (None, Some(secret_file)) => { let path = parse_secret_file(secret_file)?; let value = read_secret_file(&path)?; (value, SecretSource::File(path)) } _ => { return Err(anyhow!( "expected exactly one of secret_env_var or secret_file" )); } }; let prefix = header.prefix.clone().unwrap_or_default(); let value = HeaderValue::from_str(&format!("{prefix}{secret}")) .with_context(|| format!("invalid value for injected header {}", header.name))?; Ok(ResolvedInjectedHeader { name, value, source, })}fn hook_matches(hook: &MitmHook, req: &Request) -> bool { let method = req.method().as_str().to_ascii_uppercase(); if !hook .matcher .methods .iter() .any(|allowed| allowed == &method) { return false; } let path = req.uri().path(); if !path_matches(&hook.matcher.path_prefixes, path) { return false; } if !query_matches(&hook.matcher.query, req) { return false; } headers_match(&hook.matcher.headers, req)}fn query_matches(query_constraints: &[QueryConstraint], req: &Request) -> bool { if query_constraints.is_empty() { return true; } let actual_query = req.uri().query().unwrap_or_default(); let mut actual_values: BTreeMap<String, Vec<String>> = BTreeMap::new(); for (name, value) in form_urlencoded::parse(actual_query.as_bytes()) { actual_values .entry(name.into_owned()) .or_default() .push(value.into_owned()); } query_constraints.iter().all(|constraint| { actual_values.get(&constraint.name).is_some_and(|actual| { actual.iter().any(|candidate| { constraint .allowed_values .iter() .any(|allowed| allowed.matches(candidate)) }) }) })}fn headers_match(header_constraints: &[HeaderConstraint], req: &Request) -> bool { header_constraints.iter().all(|constraint| { let actual = req.headers().get_all(&constraint.name); if actual.iter().next().is_none() { return false; } if constraint.allowed_values.is_empty() { return true; } actual.iter().any(|value| { value.to_str().ok().is_some_and(|candidate| { constraint .allowed_values .iter() .any(|allowed| allowed.matches(candidate)) }) }) })}fn path_matches(path_prefixes: &[PathMatcher], path: &str) -> bool { path_prefixes.iter().any(|matcher| matcher.matches(path))}impl PathMatcher { fn matches(&self, candidate: &str) -> bool { match self { Self::Prefix(prefix) => candidate.starts_with(prefix), Self::Glob(glob) => glob.is_match(candidate), } }}impl ValueMatcher { fn matches(&self, candidate: &str) -> bool { match self { Self::Exact(value) => value == candidate, Self::Glob(glob) => glob.is_match(candidate), } }}fn compile_path_matchers(path_prefixes: &[String]) -> Result<Vec<PathMatcher>> { path_prefixes .iter() .map(|prefix| { match parse_matcher_pattern(prefix)? { MatcherPattern::Literal(prefix) => { if prefix.is_empty() { return Err(anyhow!("path_prefixes must not contain empty entries")); } Ok(PathMatcher::Prefix(prefix.to_string())) } MatcherPattern::Glob(glob_pattern) => Ok(PathMatcher::Glob(compile_glob_matcher( glob_pattern, /*literal_separator*/ true, )?)), } }) .collect()}fn compile_value_matchers(values: &[String]) -> Result<Vec<ValueMatcher>> { values .iter() .map(|value| match parse_matcher_pattern(value)? { MatcherPattern::Literal(value) => Ok(ValueMatcher::Exact(value.to_string())), MatcherPattern::Glob(glob_pattern) => Ok(ValueMatcher::Glob(compile_glob_matcher( glob_pattern, /*literal_separator*/ false, )?)), }) .collect()}fn parse_matcher_pattern(pattern: &str) -> Result<MatcherPattern<'_>> { if let Some(literal) = pattern.strip_prefix(LITERAL_PREFIX) { return Ok(MatcherPattern::Literal(literal)); } let Some(glob_pattern) = pattern.strip_prefix(PATTERN_PREFIX) else { return Ok(MatcherPattern::Literal(pattern)); }; if glob_pattern.is_empty() { return Err(anyhow!("glob pattern must not be empty")); } Ok(MatcherPattern::Glob(glob_pattern))}fn compile_glob_matcher(pattern: &str, literal_separator: bool) -> Result<CompiledGlobMatcher> { let mut builder = GlobBuilder::new(pattern); builder .backslash_escape(true) .literal_separator(literal_separator); builder .build() .map(|glob| CompiledGlobMatcher { pattern: pattern.to_string(), matcher: glob.compile_matcher(), }) .map_err(|err| anyhow!("invalid glob pattern {pattern:?}: {err}"))}fn normalize_hook_host(host: &str) -> Result<String> { let normalized = normalize_host(host); if normalized.is_empty() { return Err(anyhow!("host must not be empty")); } if normalized.contains('*') { return Err(anyhow!( "MITM hook hosts must be exact hosts and cannot contain wildcards" )); } Ok(normalized)}fn normalize_methods(methods: &[String]) -> Result<Vec<String>> { methods .iter() .map(|method| { let normalized = method.trim().to_ascii_uppercase(); if normalized.is_empty() { return Err(anyhow!("methods must not contain empty entries")); } Ok(normalized) }) .collect()}fn validate_query_constraints(query: &BTreeMap<String, Vec<String>>) -> Result<()> { for (name, values) in query { let normalized = normalize_query_name(name)?; if normalized.is_empty() { return Err(anyhow!("query keys must not be empty")); } if values.is_empty() { return Err(anyhow!( "query key {name:?} must list at least one allowed value" )); } let _ = compile_value_matchers(values) .with_context(|| format!("invalid matcher for query key {name:?}"))?; } Ok(())}fn normalize_query_name(name: &str) -> Result<String> { if name.is_empty() { return Err(anyhow!("query keys must not be empty")); } Ok(name.to_string())}fn validate_header_constraints(headers: &BTreeMap<String, Vec<String>>) -> Result<()> { for (name, values) in headers { let _ = parse_header_name(name)?; let _ = compile_value_matchers(values) .with_context(|| format!("invalid matcher for header {name:?}"))?; } Ok(())}fn validate_strip_request_headers(header_names: &[String]) -> Result<()> { for name in header_names { let _ = parse_header_name(name)?; } Ok(())}fn validate_injected_headers(headers: &[InjectedHeaderConfig]) -> Result<()> { for header in headers { let _ = parse_header_name(&header.name)?; match ( header.secret_env_var.as_deref(), header.secret_file.as_deref(), ) { (Some(secret_env_var), None) => { if secret_env_var.trim().is_empty() { return Err(anyhow!("secret_env_var must not be empty")); } } (None, Some(secret_file)) => { let _ = parse_secret_file(secret_file)?; } _ => { return Err(anyhow!( "expected exactly one of secret_env_var or secret_file" )); } } } Ok(())}fn parse_header_name(name: &str) -> Result<HeaderName> { HeaderName::from_bytes(name.as_bytes()) .map_err(|err| anyhow!("invalid header name {name:?}: {err}"))}fn parse_secret_file(path: &str) -> Result<AbsolutePathBuf> { if path.trim().is_empty() { return Err(anyhow!("secret_file must not be empty")); } let path = Path::new(path); if !path.is_absolute() { return Err(anyhow!("secret_file must be an absolute path: {path:?}")); } AbsolutePathBuf::from_absolute_path(path) .with_context(|| format!("secret_file must be an absolute path: {path:?}"))}#[cfg(test)]mod tests { use super::*; use crate::NetworkMode; use crate::config::NetworkProxySettings; use pretty_assertions::assert_eq; use rama_http::Body; use rama_http::Method; use tempfile::NamedTempFile; fn base_config() -> NetworkProxyConfig { NetworkProxyConfig { network: NetworkProxySettings { mitm: true, mode: NetworkMode::Limited, ..NetworkProxySettings::default() }, } } fn github_hook() -> MitmHookConfig { MitmHookConfig { host: "api.github.com".to_string(), matcher: MitmHookMatchConfig { methods: vec!["POST".to_string(), "PUT".to_string()], path_prefixes: vec!["/repos/openai/".to_string()], ..MitmHookMatchConfig::default() }, actions: MitmHookActionsConfig { strip_request_headers: vec!["authorization".to_string()], inject_request_headers: vec![InjectedHeaderConfig { name: "authorization".to_string(), secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()), secret_file: None, prefix: Some("Bearer ".to_string()), }], }, } } #[test] fn validate_requires_mitm_for_hooks() { let mut config = base_config(); config.network.mitm = false; config.network.mitm_hooks = vec![github_hook()]; let err = validate_mitm_hook_config(&config).expect_err("hooks require mitm"); assert!( err.to_string() .contains("network.mitm_hooks requires network.mitm = true") ); } #[test] fn validate_allows_hooks_in_full_mode() { let mut config = base_config(); config.network.mode = NetworkMode::Full; config.network.mitm_hooks = vec![github_hook()]; validate_mitm_hook_config(&config).expect("hooks should be allowed in full mode"); } #[test] fn validate_rejects_body_matchers_for_now() { let mut config = base_config(); let mut hook = github_hook(); hook.matcher.body = Some(MitmHookBodyConfig(serde_json::json!({ "repository": "openai/codex" }))); config.network.mitm_hooks = vec![hook]; let err = validate_mitm_hook_config(&config).expect_err("body matchers are reserved"); assert!(err.to_string().contains("match.body is reserved")); } #[test] fn validate_rejects_relative_secret_file() { let mut config = base_config(); let mut hook = github_hook(); hook.actions.inject_request_headers[0].secret_env_var = None; hook.actions.inject_request_headers[0].secret_file = Some("token.txt".to_string()); config.network.mitm_hooks = vec![hook]; let err = validate_mitm_hook_config(&config).expect_err("secret file must be absolute"); assert!(format!("{err:#}").contains("secret_file must be an absolute path")); } #[test] fn validate_rejects_dual_secret_sources() { let mut config = base_config(); let mut hook = github_hook(); hook.actions.inject_request_headers[0].secret_file = Some("/tmp/github-token".to_string()); config.network.mitm_hooks = vec![hook]; let err = validate_mitm_hook_config(&config).expect_err("dual secret sources invalid"); assert!(format!("{err:#}").contains("exactly one of secret_env_var or secret_file")); } #[test] fn compile_resolves_env_backed_injected_headers() { let mut config = base_config(); config.network.mitm_hooks = vec![github_hook()]; let hooks = compile_mitm_hooks_with_resolvers( &config, |name| (name == "CODEX_GITHUB_TOKEN").then(|| "ghp-secret".to_string()), |_| Err(anyhow!("unexpected file lookup")), ) .unwrap(); let compiled = hooks.get("api.github.com").unwrap(); assert_eq!(compiled.len(), 1); assert_eq!( compiled[0].actions.inject_request_headers[0].source, SecretSource::EnvVar("CODEX_GITHUB_TOKEN".to_string()) ); assert_eq!( compiled[0].actions.inject_request_headers[0].value, HeaderValue::from_static("Bearer ghp-secret") ); } #[test] fn compile_resolves_file_backed_injected_headers() { let secret_file = NamedTempFile::new().unwrap(); std::fs::write(secret_file.path(), "ghp-file-secret\n").unwrap(); let mut config = base_config(); let mut hook = github_hook(); hook.actions.inject_request_headers[0].secret_env_var = None; hook.actions.inject_request_headers[0].secret_file = Some(secret_file.path().display().to_string()); config.network.mitm_hooks = vec![hook]; let hooks = compile_mitm_hooks(&config).unwrap(); let compiled = hooks.get("api.github.com").unwrap(); assert_eq!( compiled[0].actions.inject_request_headers[0].value, HeaderValue::from_static("Bearer ghp-file-secret") ); } #[test] fn evaluate_returns_first_matching_hook() { let mut config = base_config(); let mut first = github_hook(); first.matcher.path_prefixes = vec!["/repos/openai/".to_string()]; let mut second = github_hook(); second.actions.inject_request_headers[0].prefix = Some("Token ".to_string()); config.network.mitm_hooks = vec![first, second]; let hooks = compile_mitm_hooks_with_resolvers( &config, |_| Some("abc".to_string()), |_| Err(anyhow!("unexpected file lookup")), ) .unwrap(); let req = Request::builder() .method(Method::POST) .uri("/repos/openai/codex/issues") .header("x-trace", "1") .body(Body::empty()) .unwrap(); let evaluation = evaluate_mitm_hooks(&hooks, "api.github.com", &req); let HookEvaluation::Matched { actions } = evaluation else { panic!("expected a matching hook"); }; assert_eq!( actions.inject_request_headers[0].value, HeaderValue::from_static("Bearer abc") ); } #[test] fn evaluate_matches_query_and_header_constraints() { let mut config = base_config(); let mut hook = github_hook(); hook.matcher.query = BTreeMap::from([( "state".to_string(), vec!["open".to_string(), "triage".to_string()], )]); hook.matcher.headers = BTreeMap::from([( "x-github-api-version".to_string(), vec!["2022-11-28".to_string()], )]); config.network.mitm_hooks = vec![hook]; let hooks = compile_mitm_hooks_with_resolvers( &config, |_| Some("abc".to_string()), |_| Err(anyhow!("unexpected file lookup")), ) .unwrap(); let req = Request::builder() .method(Method::POST) .uri("/repos/openai/codex/issues?state=open&per_page=10") .header("x-github-api-version", "2022-11-28") .body(Body::empty()) .unwrap(); assert_eq!( evaluate_mitm_hooks(&hooks, "api.github.com", &req), HookEvaluation::Matched { actions: hooks.get("api.github.com").unwrap()[0].actions.clone(), } ); } #[test] fn evaluate_matches_wildcard_path_query_and_header_constraints() { let mut config = base_config(); let mut hook = github_hook(); hook.matcher.path_prefixes = vec!["pattern:/repos/*/codex/issues*".to_string()]; hook.matcher.query = BTreeMap::from([("state".to_string(), vec!["pattern:op*".to_string()])]); hook.matcher.headers = BTreeMap::from([( "x-github-api-version".to_string(), vec!["pattern:2022*preview".to_string()], )]); config.network.mitm_hooks = vec![hook]; let hooks = compile_mitm_hooks_with_resolvers( &config, |_| Some("abc".to_string()), |_| Err(anyhow!("unexpected file lookup")), ) .unwrap(); let req = Request::builder() .method(Method::POST) .uri("/repos/openai/codex/issues?state=open") .header("x-github-api-version", "2022-11-28-preview") .body(Body::empty()) .unwrap(); assert_eq!( evaluate_mitm_hooks(&hooks, "api.github.com", &req), HookEvaluation::Matched { actions: hooks.get("api.github.com").unwrap()[0].actions.clone(), } ); } #[test] fn validate_rejects_invalid_wildcard_path_pattern() { let mut config = base_config(); let mut hook = github_hook(); hook.matcher.path_prefixes = vec!["pattern:/repos/[".to_string()]; config.network.mitm_hooks = vec![hook]; let err = validate_mitm_hook_config(&config).expect_err("invalid glob should fail"); assert!(format!("{err:#}").contains("invalid glob pattern")); } #[test] fn evaluate_path_wildcard_does_not_cross_segment_boundaries() { let mut config = base_config(); let mut hook = github_hook(); hook.matcher.path_prefixes = vec!["pattern:/repos/*/codex/issues*".to_string()]; config.network.mitm_hooks = vec![hook]; let hooks = compile_mitm_hooks_with_resolvers( &config, |_| Some("abc".to_string()), |_| Err(anyhow!("unexpected file lookup")), ) .unwrap(); let nested_req = Request::builder() .method(Method::POST) .uri("/repos/openai/private/codex/issues") .body(Body::empty()) .unwrap(); assert_eq!( evaluate_mitm_hooks(&hooks, "api.github.com", &nested_req), HookEvaluation::HookedHostNoMatch ); } #[test] fn evaluate_treats_glob_metacharacters_as_literal_without_glob_prefix() { let mut config = base_config(); let mut hook = github_hook(); hook.matcher.path_prefixes = vec!["/repos/[draft]/".to_string()]; hook.matcher.query = BTreeMap::from([("state".to_string(), vec!["op*".to_string()])]); hook.matcher.headers = BTreeMap::from([( "x-github-api-version".to_string(), vec!["2022-11-28[preview]".to_string()], )]); config.network.mitm_hooks = vec![hook]; let hooks = compile_mitm_hooks_with_resolvers( &config, |_| Some("abc".to_string()), |_| Err(anyhow!("unexpected file lookup")), ) .unwrap(); let exact_req = Request::builder() .method(Method::POST) .uri("/repos/[draft]/codex/issues?state=op*") .header("x-github-api-version", "2022-11-28[preview]") .body(Body::empty()) .unwrap(); let non_literal_req = Request::builder() .method(Method::POST) .uri("/repos/draft/codex/issues?state=open") .header("x-github-api-version", "2022-11-28-preview") .body(Body::empty()) .unwrap(); assert_eq!( evaluate_mitm_hooks(&hooks, "api.github.com", &exact_req), HookEvaluation::Matched { actions: hooks.get("api.github.com").unwrap()[0].actions.clone(), } ); assert_eq!( evaluate_mitm_hooks(&hooks, "api.github.com", &non_literal_req), HookEvaluation::HookedHostNoMatch ); } #[test] fn evaluate_allows_literal_values_with_reserved_prefixes() { let mut config = base_config(); let mut hook = github_hook(); hook.matcher.query = BTreeMap::from([("state".to_string(), vec!["literal:pattern:*".to_string()])]); hook.matcher.headers = BTreeMap::from([( "x-github-api-version".to_string(), vec!["literal:pattern:*".to_string()], )]); config.network.mitm_hooks = vec![hook]; let hooks = compile_mitm_hooks_with_resolvers( &config, |_| Some("abc".to_string()), |_| Err(anyhow!("unexpected file lookup")), ) .unwrap(); let exact_req = Request::builder() .method(Method::POST) .uri("/repos/openai/codex/issues?state=pattern%3A%2A") .header("x-github-api-version", "pattern:*") .body(Body::empty()) .unwrap(); let non_literal_req = Request::builder() .method(Method::POST) .uri("/repos/openai/codex/issues?state=pattern%3Aopen") .header("x-github-api-version", "pattern:preview") .body(Body::empty()) .unwrap(); assert_eq!( evaluate_mitm_hooks(&hooks, "api.github.com", &exact_req), HookEvaluation::Matched { actions: hooks.get("api.github.com").unwrap()[0].actions.clone(), } ); assert_eq!( evaluate_mitm_hooks(&hooks, "api.github.com", &non_literal_req), HookEvaluation::HookedHostNoMatch ); } #[test] fn evaluate_returns_hooked_host_no_match_when_query_constraint_fails() { let mut config = base_config(); let mut hook = github_hook(); hook.matcher.query = BTreeMap::from([("state".to_string(), vec!["open".to_string()])]); config.network.mitm_hooks = vec![hook]; let hooks = compile_mitm_hooks_with_resolvers( &config, |_| Some("abc".to_string()), |_| Err(anyhow!("unexpected file lookup")), ) .unwrap(); let req = Request::builder() .method(Method::POST) .uri("/repos/openai/codex/issues?state=closed") .body(Body::empty()) .unwrap(); assert_eq!( evaluate_mitm_hooks(&hooks, "api.github.com", &req), HookEvaluation::HookedHostNoMatch ); } #[test] fn evaluate_returns_no_hooks_for_unconfigured_host() { let req = Request::builder() .method(Method::POST) .uri("/repos/openai/codex/issues") .body(Body::empty()) .unwrap(); assert_eq!( evaluate_mitm_hooks(&MitmHooksByHost::new(), "api.github.com", &req), HookEvaluation::NoHooksForHost ); }}