#![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, pub path_prefixes: Vec, pub query: BTreeMap>, pub headers: BTreeMap>, pub body: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] #[serde(default)] pub struct MitmHookActionsConfig { pub strip_request_headers: Vec, pub inject_request_headers: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] #[serde(default)] pub struct InjectedHeaderConfig { pub name: String, pub secret_env_var: Option, pub secret_file: Option, pub prefix: Option, } #[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, pub path_prefixes: Vec, pub query: Vec, pub headers: Vec, pub body: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct QueryConstraint { pub name: String, pub allowed_values: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct HeaderConstraint { pub name: HeaderName, pub allowed_values: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct MitmHookActions { pub strip_request_headers: Vec, pub inject_request_headers: Vec, } #[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>; #[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 { 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( config: &NetworkProxyConfig, resolve_env_var: EnvFn, read_secret_file: FileFn, ) -> Result where EnvFn: Fn(&str) -> Option, FileFn: Fn(&AbsolutePathBuf) -> Result, { 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::>>()?; let headers = hook .matcher .headers .iter() .map(|(name, values)| { Ok(HeaderConstraint { name: parse_header_name(name)?, allowed_values: compile_value_matchers(values)?, }) }) .collect::>>()?; let strip_request_headers = hook .actions .strip_request_headers .iter() .map(|name| parse_header_name(name)) .collect::>>()?; 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::>>()?; 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( header: &InjectedHeaderConfig, resolve_env_var: &EnvFn, read_secret_file: &FileFn, ) -> Result where EnvFn: Fn(&str) -> Option, FileFn: Fn(&AbsolutePathBuf) -> Result, { 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> = 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> { 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> { 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> { 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 { 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 { 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> { 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>) -> 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 { if name.is_empty() { return Err(anyhow!("query keys must not be empty")); } Ok(name.to_string()) } fn validate_header_constraints(headers: &BTreeMap>) -> 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::from_bytes(name.as_bytes()) .map_err(|err| anyhow!("invalid header name {name:?}: {err}")) } fn parse_secret_file(path: &str) -> Result { 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 ); } }