This is page 9 of 11. Use http://codebase.md/tuananh/hyper-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .cursor
│ └── rules
│ └── print-ctx-size.mdc
├── .dockerignore
├── .github
│ ├── renovate.json5
│ └── workflows
│ ├── ci.yml
│ ├── nightly.yml
│ └── release.yml
├── .gitignore
├── .gitmodules
├── .hadolint.yaml
├── .pre-commit-config.yaml
├── .windsurf
│ └── rules
│ ├── print-ctx-size.md
│ └── think.md
├── assets
│ ├── cursor-mcp-1.png
│ ├── cursor-mcp.png
│ ├── eval-py.jpg
│ └── logo.png
├── Cargo.lock
├── Cargo.toml
├── config.example.json
├── config.example.yaml
├── CREATING_PLUGINS.md
├── DEPLOYMENT.md
├── Dockerfile
├── examples
│ └── plugins
│ ├── v1
│ │ ├── arxiv
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── context7
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── crates-io
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── crypto-price
│ │ │ ├── Dockerfile
│ │ │ ├── go.mod
│ │ │ ├── go.sum
│ │ │ ├── main.go
│ │ │ ├── pdk.gen.go
│ │ │ └── README.md
│ │ ├── eval-py
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── fetch
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── fs
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── github
│ │ │ ├── .gitignore
│ │ │ ├── branches.go
│ │ │ ├── Dockerfile
│ │ │ ├── files.go
│ │ │ ├── gists.go
│ │ │ ├── go.mod
│ │ │ ├── go.sum
│ │ │ ├── issues.go
│ │ │ ├── main.go
│ │ │ ├── pdk.gen.go
│ │ │ ├── README.md
│ │ │ └── repo.go
│ │ ├── gitlab
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── gomodule
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── hash
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.lock
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── maven
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── meme-generator
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── generate_embedded.py
│ │ │ ├── README.md
│ │ │ ├── src
│ │ │ │ ├── embedded.rs
│ │ │ │ ├── lib.rs
│ │ │ │ └── pdk.rs
│ │ │ └── templates.json
│ │ ├── memory
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── myip
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.lock
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── qdrant
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ ├── pdk.rs
│ │ │ └── qdrant_client.rs
│ │ ├── qr-code
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.lock
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── serper
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── sqlite
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── think
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── time
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ ├── src
│ │ │ │ ├── lib.rs
│ │ │ │ └── pdk.rs
│ │ │ └── time.wasm
│ │ └── tool-list-changed
│ │ ├── .gitignore
│ │ ├── Cargo.toml
│ │ ├── Dockerfile
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ └── tool_list_changed.wasm
│ └── v2
│ └── rstime
│ ├── .cargo
│ │ └── config.toml
│ ├── .gitignore
│ ├── Cargo.toml
│ ├── Dockerfile
│ ├── README.md
│ ├── rstime.wasm
│ └── src
│ ├── lib.rs
│ └── pdk
│ ├── exports.rs
│ ├── imports.rs
│ ├── mod.rs
│ └── types.rs
├── iac
│ ├── .terraform.lock.hcl
│ ├── main.tf
│ ├── outputs.tf
│ └── variables.tf
├── justfile
├── LICENSE
├── README.md
├── RUNTIME_CONFIG.md
├── rust-toolchain.toml
├── server.json
├── SKIP_TOOLS_GUIDE.md
├── src
│ ├── cli.rs
│ ├── config.rs
│ ├── https_auth.rs
│ ├── logging.rs
│ ├── main.rs
│ ├── naming.rs
│ ├── plugin.rs
│ ├── service.rs
│ └── wasm
│ ├── http.rs
│ ├── mod.rs
│ ├── oci.rs
│ └── s3.rs
├── templates
│ └── plugins
│ ├── go
│ │ ├── .gitignore
│ │ ├── Dockerfile
│ │ ├── exports.go
│ │ ├── go.mod
│ │ ├── go.sum
│ │ ├── imports.go
│ │ ├── main.go
│ │ ├── README.md
│ │ └── types.go
│ ├── README.md
│ └── rust
│ ├── .cargo
│ │ └── config.toml
│ ├── .gitignore
│ ├── Cargo.toml
│ ├── Dockerfile
│ ├── README.md
│ └── src
│ ├── lib.rs
│ └── pdk
│ ├── exports.rs
│ ├── imports.rs
│ ├── mod.rs
│ └── types.rs
├── tests
│ └── fixtures
│ ├── config_with_auths.json
│ ├── config_with_auths.yaml
│ ├── documentation_example.json
│ ├── documentation_example.yaml
│ ├── invalid_auth_config.yaml
│ ├── invalid_plugin_name.yaml
│ ├── invalid_structure.yaml
│ ├── invalid_url.yaml
│ ├── keyring_auth_config.yaml
│ ├── skip_tools_examples.yaml
│ ├── unsupported_config.txt
│ ├── valid_config.json
│ └── valid_config.yaml
└── xtp-plugin-schema.json
```
# Files
--------------------------------------------------------------------------------
/src/config.rs:
--------------------------------------------------------------------------------
```rust
1 | use crate::cli::Cli;
2 | use anyhow::{Context, Result};
3 | use once_cell::sync::Lazy;
4 | use regex::{Regex, RegexSet};
5 | use serde::{Deserialize, Serialize};
6 | use std::{collections::HashMap, convert::TryFrom, fmt, path::PathBuf, str::FromStr};
7 | use url::Url;
8 |
9 | #[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize)]
10 | pub struct PluginName(String);
11 |
12 | #[derive(Clone, Debug)]
13 | pub struct PluginNameParseError;
14 |
15 | impl fmt::Display for PluginNameParseError {
16 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17 | write!(f, "Failed to parse plugin name")
18 | }
19 | }
20 |
21 | impl std::error::Error for PluginNameParseError {}
22 |
23 | static PLUGIN_NAME_REGEX: Lazy<Regex> = Lazy::new(|| {
24 | Regex::new(r"^[A-Za-z0-9]+(?:[_][A-Za-z0-9]+)*$").expect("Failed to compile plugin name regex")
25 | });
26 |
27 | impl PluginName {
28 | #[allow(dead_code)]
29 | pub fn as_str(&self) -> &str {
30 | &self.0
31 | }
32 | }
33 |
34 | impl<'de> Deserialize<'de> for PluginName {
35 | fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
36 | where
37 | D: serde::Deserializer<'de>,
38 | {
39 | let s = String::deserialize(deserializer)?;
40 | PluginName::try_from(s.as_str()).map_err(serde::de::Error::custom)
41 | }
42 | }
43 |
44 | impl TryFrom<&str> for PluginName {
45 | type Error = PluginNameParseError;
46 |
47 | fn try_from(value: &str) -> Result<Self, Self::Error> {
48 | if PLUGIN_NAME_REGEX.is_match(value) {
49 | Ok(PluginName(value.to_owned()))
50 | } else {
51 | Err(PluginNameParseError)
52 | }
53 | }
54 | }
55 |
56 | impl TryFrom<String> for PluginName {
57 | type Error = PluginNameParseError;
58 |
59 | fn try_from(value: String) -> Result<Self, Self::Error> {
60 | PluginName::try_from(value.as_str())
61 | }
62 | }
63 |
64 | impl TryFrom<&String> for PluginName {
65 | type Error = PluginNameParseError;
66 |
67 | fn try_from(value: &String) -> Result<Self, Self::Error> {
68 | PluginName::try_from(value.as_str())
69 | }
70 | }
71 |
72 | impl FromStr for PluginName {
73 | type Err = PluginNameParseError;
74 |
75 | fn from_str(s: &str) -> Result<Self, Self::Err> {
76 | PluginName::try_from(s)
77 | }
78 | }
79 |
80 | impl fmt::Display for PluginName {
81 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 | write!(f, "{}", self.0)
83 | }
84 | }
85 |
86 | #[derive(Clone, Debug, Serialize)]
87 | #[serde(tag = "type", rename_all = "lowercase")]
88 | pub enum AuthConfig {
89 | Basic { username: String, password: String },
90 | Token { token: String },
91 | }
92 |
93 | #[derive(Debug, Deserialize, Serialize)]
94 | #[serde(tag = "type", rename_all = "lowercase")]
95 | enum InternalAuthConfig {
96 | Basic { username: String, password: String },
97 | Keyring { service: String, user: String },
98 | Token { token: String },
99 | }
100 |
101 | impl<'de> Deserialize<'de> for AuthConfig {
102 | fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
103 | where
104 | D: serde::Deserializer<'de>,
105 | {
106 | let internal = InternalAuthConfig::deserialize(deserializer)?;
107 | match internal {
108 | InternalAuthConfig::Basic { username, password } => {
109 | Ok(AuthConfig::Basic { username, password })
110 | }
111 | InternalAuthConfig::Token { token } => Ok(AuthConfig::Token { token }),
112 | InternalAuthConfig::Keyring { service, user } => {
113 | use keyring::Entry;
114 | use serde::de;
115 |
116 | let entry =
117 | Entry::new(service.as_str(), user.as_str()).map_err(de::Error::custom)?;
118 | let secret = entry.get_secret().map_err(de::Error::custom)?;
119 | Ok(serde_json::from_slice::<AuthConfig>(secret.as_slice())
120 | .map_err(de::Error::custom)?)
121 | }
122 | }
123 | }
124 | }
125 |
126 | #[derive(Clone, Debug, Default, Deserialize, Serialize)]
127 | pub struct Config {
128 | #[serde(skip_serializing_if = "Option::is_none")]
129 | pub auths: Option<HashMap<Url, AuthConfig>>,
130 |
131 | #[serde(default)]
132 | pub oci: OciConfig,
133 |
134 | pub plugins: HashMap<PluginName, PluginConfig>,
135 | }
136 |
137 | #[derive(Clone, Debug, Deserialize, Serialize)]
138 | pub struct OciConfig {
139 | #[serde(skip_serializing_if = "Option::is_none")]
140 | pub cert_email: Option<String>,
141 |
142 | #[serde(skip_serializing_if = "Option::is_none")]
143 | pub cert_issuer: Option<String>,
144 |
145 | #[serde(skip_serializing_if = "Option::is_none")]
146 | pub cert_url: Option<String>,
147 |
148 | #[serde(skip_serializing_if = "Option::is_none")]
149 | pub fulcio_certs: Option<PathBuf>,
150 |
151 | pub insecure_skip_signature: bool,
152 |
153 | #[serde(skip_serializing_if = "Option::is_none")]
154 | pub rekor_pub_keys: Option<PathBuf>,
155 |
156 | pub use_sigstore_tuf_data: bool,
157 | }
158 |
159 | impl Default for OciConfig {
160 | fn default() -> Self {
161 | OciConfig {
162 | cert_email: None,
163 | cert_issuer: None,
164 | cert_url: None,
165 | fulcio_certs: None,
166 | insecure_skip_signature: false,
167 | rekor_pub_keys: None,
168 | use_sigstore_tuf_data: true,
169 | }
170 | }
171 | }
172 |
173 | #[derive(Clone, Debug, Deserialize, Serialize)]
174 | pub struct PluginConfig {
175 | #[serde(rename = "url", alias = "path")]
176 | pub url: Url,
177 | pub runtime_config: Option<RuntimeConfig>,
178 | }
179 |
180 | mod skip_serde {
181 | use super::*;
182 | use serde::{Deserializer, Serializer};
183 |
184 | pub fn serialize<S>(set: &Option<RegexSet>, serializer: S) -> Result<S::Ok, S::Error>
185 | where
186 | S: Serializer,
187 | {
188 | match set {
189 | Some(set) => serializer.serialize_some(set.patterns()),
190 | None => serializer.serialize_none(),
191 | }
192 | }
193 |
194 | fn anchor_pattern(pattern: &String) -> String {
195 | // Anchor the pattern to match the entire string
196 | // only if it is not already anchored
197 | if pattern.starts_with("^")
198 | || pattern.starts_with("\\A")
199 | || pattern.ends_with("$")
200 | || pattern.ends_with("\\z")
201 | {
202 | pattern.clone()
203 | } else {
204 | format!("^{}$", pattern)
205 | }
206 | }
207 |
208 | pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<RegexSet>, D::Error>
209 | where
210 | D: Deserializer<'de>,
211 | {
212 | let patterns: Option<Vec<String>> = Option::deserialize(deserializer)?;
213 | match patterns {
214 | Some(patterns) => RegexSet::new(
215 | patterns
216 | .into_iter()
217 | .map(|p| anchor_pattern(&p))
218 | .collect::<Vec<_>>(),
219 | )
220 | .map(Some)
221 | .map_err(serde::de::Error::custom),
222 | None => Ok(None),
223 | }
224 | }
225 | }
226 |
227 | #[derive(Clone, Debug, Default, Deserialize, Serialize)]
228 | pub struct RuntimeConfig {
229 | // List of prompts to skip loading at runtime.
230 | #[serde(with = "skip_serde", default)]
231 | pub skip_prompts: Option<RegexSet>,
232 | // List of resource templatess to skip loading at runtime.
233 | #[serde(with = "skip_serde", default)]
234 | pub skip_resource_templates: Option<RegexSet>,
235 | // List of resources to skip loading at runtime.
236 | #[serde(with = "skip_serde", default)]
237 | pub skip_resources: Option<RegexSet>,
238 | // List of tools to skip loading at runtime.
239 | #[serde(with = "skip_serde", default)]
240 | pub skip_tools: Option<RegexSet>,
241 | pub allowed_hosts: Option<Vec<String>>,
242 | pub allowed_paths: Option<Vec<String>>,
243 | pub env_vars: Option<HashMap<String, String>>,
244 | pub memory_limit: Option<String>,
245 | }
246 |
247 | pub async fn load_config(cli: &Cli) -> Result<Config> {
248 | // Get default config path in the user's config directory
249 | let default_config_path = dirs::config_dir()
250 | .map(|mut path| {
251 | path.push("hyper-mcp");
252 | path.push("config.json");
253 | path
254 | })
255 | .unwrap();
256 |
257 | let config_path = cli.config_file.as_ref().unwrap_or(&default_config_path);
258 | if !config_path.exists() {
259 | return Err(anyhow::anyhow!(
260 | "Config file not found at: {}. Please create a config file first.",
261 | config_path.display()
262 | ));
263 | }
264 | tracing::info!("Using config file at {}", config_path.display());
265 | let ext = config_path
266 | .extension()
267 | .and_then(|e| e.to_str())
268 | .unwrap_or("");
269 |
270 | let content = tokio::fs::read_to_string(config_path)
271 | .await
272 | .with_context(|| format!("Failed to read config file at {}", config_path.display()))?;
273 |
274 | let mut config: Config = match ext {
275 | "json" => serde_json::from_str(&content)?,
276 | "yaml" | "yml" => serde_yaml::from_str(&content)?,
277 | "toml" => toml::from_str(&content)?,
278 | _ => return Err(anyhow::anyhow!("Unsupported config format: {ext}")),
279 | };
280 |
281 | let mut oci = config.oci.clone();
282 |
283 | if let Some(skip) = cli.insecure_skip_signature {
284 | oci.insecure_skip_signature = skip;
285 | }
286 | if let Some(use_tuf) = cli.use_sigstore_tuf_data {
287 | oci.use_sigstore_tuf_data = use_tuf;
288 | }
289 | if let Some(rekor_keys) = &cli.rekor_pub_keys {
290 | oci.rekor_pub_keys = Some(rekor_keys.clone());
291 | }
292 | if let Some(fulcio_certs) = &cli.fulcio_certs {
293 | oci.fulcio_certs = Some(fulcio_certs.clone());
294 | }
295 | if let Some(issuer) = &cli.cert_issuer {
296 | oci.cert_issuer = Some(issuer.clone());
297 | }
298 | if let Some(email) = &cli.cert_email {
299 | oci.cert_email = Some(email.clone());
300 | }
301 | if let Some(url) = &cli.cert_url {
302 | oci.cert_url = Some(url.clone());
303 | }
304 | config.oci = oci;
305 |
306 | Ok(config)
307 | }
308 |
309 | #[cfg(test)]
310 | mod tests {
311 | use super::*;
312 | use std::path::Path;
313 | use tokio::runtime::Runtime;
314 |
315 | #[test]
316 | fn test_plugin_name_valid() {
317 | let valid_names = vec!["plugin1", "plugin_name", "PluginName", "plugin123"];
318 |
319 | for name in valid_names {
320 | assert!(
321 | PluginName::try_from(name).is_ok(),
322 | "Failed to parse valid name: {name}"
323 | );
324 | }
325 | }
326 |
327 | #[test]
328 | fn test_plugin_name_invalid_comprehensive() {
329 | // Test various hyphen scenarios - hyphens are no longer allowed
330 | let hyphen_cases = vec![
331 | ("plugin-name", "single hyphen"),
332 | ("plugin-name-test", "multiple hyphens"),
333 | ("-plugin", "leading hyphen"),
334 | ("plugin-", "trailing hyphen"),
335 | ("--plugin", "leading double hyphen"),
336 | ("plugin--", "trailing double hyphen"),
337 | ("plugin--name", "consecutive hyphens"),
338 | ("plugin-_name", "hyphen before underscore"),
339 | ("plugin_-name", "hyphen after underscore"),
340 | ("my-plugin-123", "hyphens with numbers"),
341 | ("Plugin-Name", "hyphens with capitals"),
342 | ];
343 |
344 | for (name, description) in hyphen_cases {
345 | assert!(
346 | PluginName::try_from(name).is_err(),
347 | "Should reject plugin name '{name}' ({description})"
348 | );
349 | }
350 |
351 | // Test underscore edge cases
352 | let underscore_cases = vec![
353 | ("_plugin", "leading underscore"),
354 | ("plugin_", "trailing underscore"),
355 | ("__plugin", "leading double underscore"),
356 | ("plugin__", "trailing double underscore"),
357 | ("plugin__name", "consecutive underscores"),
358 | ("_plugin_", "leading and trailing underscores"),
359 | ];
360 |
361 | for (name, description) in underscore_cases {
362 | assert!(
363 | PluginName::try_from(name).is_err(),
364 | "Should reject plugin name '{name}' ({description})"
365 | );
366 | }
367 |
368 | // Test special characters
369 | let special_char_cases = vec![
370 | ("plugin@name", "at symbol"),
371 | ("plugin#name", "hash symbol"),
372 | ("plugin$name", "dollar sign"),
373 | ("plugin%name", "percent sign"),
374 | ("plugin&name", "ampersand"),
375 | ("plugin*name", "asterisk"),
376 | ("plugin(name)", "parentheses"),
377 | ("plugin+name", "plus sign"),
378 | ("plugin=name", "equals sign"),
379 | ("plugin[name]", "square brackets"),
380 | ("plugin{name}", "curly braces"),
381 | ("plugin|name", "pipe symbol"),
382 | ("plugin\\name", "backslash"),
383 | ("plugin:name", "colon"),
384 | ("plugin;name", "semicolon"),
385 | ("plugin\"name", "double quote"),
386 | ("plugin'name", "single quote"),
387 | ("plugin<name>", "angle brackets"),
388 | ("plugin,name", "comma"),
389 | ("plugin.name", "period"),
390 | ("plugin/name", "forward slash"),
391 | ("plugin?name", "question mark"),
392 | ];
393 |
394 | for (name, description) in special_char_cases {
395 | assert!(
396 | PluginName::try_from(name).is_err(),
397 | "Should reject plugin name '{name}' ({description})"
398 | );
399 | }
400 |
401 | // Test whitespace cases
402 | let whitespace_cases = vec![
403 | ("plugin name", "space in middle"),
404 | (" plugin", "leading space"),
405 | ("plugin ", "trailing space"),
406 | (" plugin", "leading double space"),
407 | ("plugin ", "trailing double space"),
408 | ("plugin name", "double space in middle"),
409 | ("plugin\tname", "tab character"),
410 | ("plugin\nname", "newline character"),
411 | ("plugin\rname", "carriage return"),
412 | ];
413 |
414 | for (name, description) in whitespace_cases {
415 | assert!(
416 | PluginName::try_from(name).is_err(),
417 | "Should reject plugin name '{name}' ({description})"
418 | );
419 | }
420 |
421 | // Test empty and minimal cases
422 | let empty_cases = vec![
423 | ("", "empty string"),
424 | ("_", "single underscore"),
425 | ("-", "single hyphen"),
426 | ("__", "double underscore"),
427 | ("--", "double hyphen"),
428 | ("_-", "underscore-hyphen"),
429 | ("-_", "hyphen-underscore"),
430 | ];
431 |
432 | for (name, description) in empty_cases {
433 | assert!(
434 | PluginName::try_from(name).is_err(),
435 | "Should reject plugin name '{name}' ({description})"
436 | );
437 | }
438 |
439 | // Test unicode and non-ASCII cases
440 | let unicode_cases = vec![
441 | ("plugín", "accented character"),
442 | ("plügïn", "umlaut characters"),
443 | ("плагин", "cyrillic characters"),
444 | ("プラグイン", "japanese characters"),
445 | ("插件", "chinese characters"),
446 | ("plugin名前", "mixed ASCII and japanese"),
447 | ("café-plugin", "accented character with hyphen"),
448 | ];
449 |
450 | for (name, description) in unicode_cases {
451 | assert!(
452 | PluginName::try_from(name).is_err(),
453 | "Should reject plugin name '{name}' ({description})"
454 | );
455 | }
456 | }
457 |
458 | #[test]
459 | fn test_plugin_name_valid_comprehensive() {
460 | // Test basic alphanumeric names
461 | let basic_cases = vec![
462 | ("plugin", "simple lowercase"),
463 | ("Plugin", "simple capitalized"),
464 | ("PLUGIN", "simple uppercase"),
465 | ("MyPlugin", "camelCase"),
466 | ("plugin123", "with numbers"),
467 | ("123plugin", "starting with numbers"),
468 | ("p", "single character"),
469 | ("P", "single uppercase character"),
470 | ("1", "single number"),
471 | ];
472 |
473 | for (name, description) in basic_cases {
474 | assert!(
475 | PluginName::try_from(name).is_ok(),
476 | "Should accept valid plugin name '{name}' ({description})"
477 | );
478 | }
479 |
480 | // Test names with underscores as separators
481 | let underscore_cases = vec![
482 | ("plugin_name", "simple underscore"),
483 | ("my_plugin", "underscore separator"),
484 | ("plugin_name_test", "multiple underscores"),
485 | ("Plugin_Name", "underscore with capitals"),
486 | ("plugin_123", "underscore with numbers"),
487 | ("my_plugin_v2", "complex with version"),
488 | ("a_b", "minimal underscore case"),
489 | ("test_plugin_name_123", "long with mixed content"),
490 | ];
491 |
492 | for (name, description) in underscore_cases {
493 | assert!(
494 | PluginName::try_from(name).is_ok(),
495 | "Should accept valid plugin name '{name}' ({description})"
496 | );
497 | }
498 |
499 | // Test mixed alphanumeric cases
500 | let mixed_cases = vec![
501 | ("plugin1", "letters and single digit"),
502 | ("plugin123", "letters and multiple digits"),
503 | ("Plugin1Name", "mixed case with digits"),
504 | ("myPlugin2", "camelCase with digit"),
505 | ("testPlugin123", "longer mixed case"),
506 | ("ABC123", "all caps with numbers"),
507 | ("plugin1_test2", "mixed with underscore"),
508 | ("My_Plugin_V123", "complex mixed case"),
509 | ];
510 |
511 | for (name, description) in mixed_cases {
512 | assert!(
513 | PluginName::try_from(name).is_ok(),
514 | "Should accept valid plugin name '{name}' ({description})"
515 | );
516 | }
517 |
518 | // Test longer valid names
519 | let longer_cases = vec![
520 | (
521 | "very_long_plugin_name_that_should_be_valid",
522 | "very long name",
523 | ),
524 | (
525 | "plugin_with_many_underscores_and_numbers_123",
526 | "long mixed content",
527 | ),
528 | ("MyVeryLongPluginNameThatShouldWork", "long camelCase"),
529 | ("VERY_LONG_UPPERCASE_PLUGIN_NAME", "long uppercase"),
530 | ];
531 |
532 | for (name, description) in longer_cases {
533 | assert!(
534 | PluginName::try_from(name).is_ok(),
535 | "Should accept valid plugin name '{name}' ({description})"
536 | );
537 | }
538 |
539 | // Test edge cases that should be valid
540 | let edge_cases = vec![
541 | ("a1", "minimal valid case"),
542 | ("1a", "number then letter"),
543 | ("a_1", "letter underscore number"),
544 | ("1_a", "number underscore letter"),
545 | ];
546 |
547 | for (name, description) in edge_cases {
548 | assert!(
549 | PluginName::try_from(name).is_ok(),
550 | "Should accept valid plugin name '{name}' ({description})"
551 | );
552 | }
553 | }
554 |
555 | #[test]
556 | fn test_plugin_name_display() {
557 | let name_str = "plugin_name_123";
558 | let plugin_name = PluginName::try_from(name_str).unwrap();
559 | assert_eq!(plugin_name.to_string(), name_str);
560 | }
561 |
562 | #[test]
563 | fn test_plugin_name_serialize_deserialize() {
564 | let name_str = "plugin_name_123";
565 | let plugin_name = PluginName::try_from(name_str).unwrap();
566 |
567 | // Serialize
568 | let serialized = serde_json::to_string(&plugin_name).unwrap();
569 | assert_eq!(serialized, format!("\"{name_str}\""));
570 |
571 | // Deserialize
572 | let deserialized: PluginName = serde_json::from_str(&serialized).unwrap();
573 | assert_eq!(deserialized, plugin_name);
574 | }
575 |
576 | #[test]
577 | fn test_load_valid_yaml_config() {
578 | let rt = Runtime::new().unwrap();
579 |
580 | // Read the test fixture file
581 | let path = Path::new("tests/fixtures/valid_config.yaml");
582 |
583 | let cli = Cli {
584 | config_file: Some(path.to_path_buf()),
585 |
586 | ..Default::default()
587 | };
588 |
589 | // Load the config
590 | let config_result = rt.block_on(load_config(&cli));
591 | assert!(config_result.is_ok(), "Failed to load valid YAML config");
592 |
593 | let config = config_result.unwrap();
594 | assert_eq!(config.plugins.len(), 3, "Expected 3 plugins in the config");
595 |
596 | // Verify plugin names
597 | assert!(
598 | config
599 | .plugins
600 | .contains_key(&PluginName("test_plugin".to_string()))
601 | );
602 | assert!(
603 | config
604 | .plugins
605 | .contains_key(&PluginName("another_plugin".to_string()))
606 | );
607 | assert!(
608 | config
609 | .plugins
610 | .contains_key(&PluginName("minimal_plugin".to_string()))
611 | );
612 |
613 | // Verify plugin configs
614 | let test_plugin = &config.plugins[&PluginName("test_plugin".to_string())];
615 | assert_eq!(test_plugin.url.to_string(), "file:///path/to/plugin");
616 |
617 | let runtime_config = test_plugin.runtime_config.as_ref().unwrap();
618 | assert_eq!(runtime_config.skip_tools.as_ref().unwrap().len(), 2);
619 | assert_eq!(runtime_config.allowed_hosts.as_ref().unwrap().len(), 2);
620 | assert_eq!(runtime_config.allowed_paths.as_ref().unwrap().len(), 2);
621 | assert_eq!(runtime_config.env_vars.as_ref().unwrap().len(), 2);
622 | assert_eq!(runtime_config.memory_limit.as_ref().unwrap(), "1GB");
623 |
624 | // Verify minimal plugin has no runtime config
625 | let minimal_plugin = &config.plugins[&PluginName("minimal_plugin".to_string())];
626 | assert!(minimal_plugin.runtime_config.is_none());
627 | }
628 |
629 | #[test]
630 | fn test_load_valid_json_config() {
631 | let rt = Runtime::new().unwrap();
632 |
633 | // Read the test fixture file
634 | let path = Path::new("tests/fixtures/valid_config.json");
635 |
636 | let cli = Cli {
637 | config_file: Some(path.to_path_buf()),
638 |
639 | ..Default::default()
640 | };
641 |
642 | // Load the config
643 | let config_result = rt.block_on(load_config(&cli));
644 |
645 | assert!(config_result.is_ok(), "Failed to load valid JSON config");
646 |
647 | let config = config_result.unwrap();
648 | assert_eq!(config.plugins.len(), 3, "Expected 3 plugins in the config");
649 |
650 | // Verify plugin names
651 | assert!(
652 | config
653 | .plugins
654 | .contains_key(&PluginName("test_plugin".to_string()))
655 | );
656 | assert!(
657 | config
658 | .plugins
659 | .contains_key(&PluginName("another_plugin".to_string()))
660 | );
661 | assert!(
662 | config
663 | .plugins
664 | .contains_key(&PluginName("minimal_plugin".to_string()))
665 | );
666 |
667 | // Verify env vars
668 | let test_plugin = &config.plugins[&PluginName("test_plugin".to_string())];
669 | let runtime_config = test_plugin.runtime_config.as_ref().unwrap();
670 | assert_eq!(runtime_config.env_vars.as_ref().unwrap()["DEBUG"], "true");
671 | assert_eq!(
672 | runtime_config.env_vars.as_ref().unwrap()["LOG_LEVEL"],
673 | "info"
674 | );
675 | }
676 |
677 | #[test]
678 | fn test_load_invalid_plugin_name() {
679 | let rt = Runtime::new().unwrap();
680 |
681 | // Read the test fixture file
682 | let path = Path::new("tests/fixtures/invalid_plugin_name.yaml");
683 |
684 | let cli = Cli {
685 | config_file: Some(path.to_path_buf()),
686 |
687 | ..Default::default()
688 | };
689 |
690 | // Load the config
691 | let config_result = rt.block_on(load_config(&cli));
692 | assert!(
693 | config_result.is_err(),
694 | "Expected error for invalid plugin name"
695 | );
696 | }
697 |
698 | #[test]
699 | fn test_load_invalid_url() {
700 | let rt = Runtime::new().unwrap();
701 |
702 | // Read the test fixture file
703 | let path = Path::new("tests/fixtures/invalid_url.yaml");
704 |
705 | let cli = Cli {
706 | config_file: Some(path.to_path_buf()),
707 |
708 | ..Default::default()
709 | };
710 |
711 | // Load the config
712 | let config_result = rt.block_on(load_config(&cli));
713 | assert!(config_result.is_err(), "Expected error for invalid URL");
714 |
715 | let error = config_result.unwrap_err();
716 | assert!(
717 | error.to_string().contains("not a valid url")
718 | || error.to_string().contains("invalid URL"),
719 | "Error should mention the invalid URL"
720 | );
721 | }
722 |
723 | #[test]
724 | fn test_load_invalid_structure() {
725 | let rt = Runtime::new().unwrap();
726 |
727 | // Read the test fixture file
728 | let path = Path::new("tests/fixtures/invalid_structure.yaml");
729 |
730 | let cli = Cli {
731 | config_file: Some(path.to_path_buf()),
732 |
733 | ..Default::default()
734 | };
735 |
736 | // Load the config
737 | let config_result = rt.block_on(load_config(&cli));
738 | assert!(
739 | config_result.is_err(),
740 | "Expected error for invalid structure"
741 | );
742 | }
743 |
744 | #[test]
745 | fn test_load_nonexistent_file() {
746 | let rt = Runtime::new().unwrap();
747 |
748 | // Create a path that doesn't exist
749 | let nonexistent_path = Path::new("/tmp/definitely_not_a_real_config_file_12345.yaml");
750 |
751 | let cli = Cli {
752 | config_file: Some(nonexistent_path.to_path_buf()),
753 |
754 | ..Default::default()
755 | };
756 |
757 | // Load the config
758 | let config_result = rt.block_on(load_config(&cli));
759 | assert!(
760 | config_result.is_err(),
761 | "Expected error for nonexistent file"
762 | );
763 |
764 | let error = config_result.unwrap_err();
765 | assert!(
766 | error.to_string().contains("not found"),
767 | "Error should mention file not found"
768 | );
769 | }
770 |
771 | #[test]
772 | fn test_load_unsupported_extension() {
773 | let rt = Runtime::new().unwrap();
774 |
775 | let path = Path::new("tests/fixtures/unsupported_config.txt");
776 |
777 | let cli = Cli {
778 | config_file: Some(path.to_path_buf()),
779 |
780 | ..Default::default()
781 | };
782 |
783 | // Load the config
784 | let config_result = rt.block_on(load_config(&cli));
785 | assert!(
786 | config_result.is_err(),
787 | "Expected error for unsupported extension"
788 | );
789 |
790 | let error = config_result.unwrap_err();
791 | assert!(
792 | error.to_string().contains("Unsupported config format"),
793 | "Error should mention unsupported format"
794 | );
795 | }
796 |
797 | #[test]
798 | fn test_auth_config_basic_serialization() {
799 | let auth_config = AuthConfig::Basic {
800 | username: "testuser".to_string(),
801 | password: "testpass".to_string(),
802 | };
803 |
804 | let serialized = serde_json::to_string(&auth_config).unwrap();
805 | let expected = r#"{"type":"basic","username":"testuser","password":"testpass"}"#;
806 | assert_eq!(serialized, expected);
807 | }
808 |
809 | #[test]
810 | fn test_auth_config_token_serialization() {
811 | let auth_config = AuthConfig::Token {
812 | token: "test-token-123".to_string(),
813 | };
814 |
815 | let serialized = serde_json::to_string(&auth_config).unwrap();
816 | let expected = r#"{"type":"token","token":"test-token-123"}"#;
817 | assert_eq!(serialized, expected);
818 | }
819 |
820 | #[test]
821 | fn test_auth_config_basic_deserialization() {
822 | let json = r#"{"type":"basic","username":"testuser","password":"testpass"}"#;
823 | let auth_config: AuthConfig = serde_json::from_str(json).unwrap();
824 |
825 | match auth_config {
826 | AuthConfig::Basic { username, password } => {
827 | assert_eq!(username, "testuser");
828 | assert_eq!(password, "testpass");
829 | }
830 | _ => panic!("Expected Basic auth config"),
831 | }
832 | }
833 |
834 | #[test]
835 | fn test_auth_config_token_deserialization() {
836 | let json = r#"{"type":"token","token":"test-token-123"}"#;
837 | let auth_config: AuthConfig = serde_json::from_str(json).unwrap();
838 |
839 | match auth_config {
840 | AuthConfig::Token { token } => {
841 | assert_eq!(token, "test-token-123");
842 | }
843 | _ => panic!("Expected Token auth config"),
844 | }
845 | }
846 |
847 | #[test]
848 | fn test_auth_config_yaml_basic_deserialization() {
849 | let yaml = r#"
850 | type: basic
851 | username: testuser
852 | password: testpass
853 | "#;
854 | let auth_config: AuthConfig = serde_yaml::from_str(yaml).unwrap();
855 |
856 | match auth_config {
857 | AuthConfig::Basic { username, password } => {
858 | assert_eq!(username, "testuser");
859 | assert_eq!(password, "testpass");
860 | }
861 | _ => panic!("Expected Basic auth config"),
862 | }
863 | }
864 |
865 | #[test]
866 | fn test_auth_config_yaml_token_deserialization() {
867 | let yaml = r#"
868 | type: token
869 | token: test-token-123
870 | "#;
871 | let auth_config: AuthConfig = serde_yaml::from_str(yaml).unwrap();
872 |
873 | match auth_config {
874 | AuthConfig::Token { token } => {
875 | assert_eq!(token, "test-token-123");
876 | }
877 | _ => panic!("Expected Token auth config"),
878 | }
879 | }
880 |
881 | #[test]
882 | fn test_auth_config_invalid_type() {
883 | let json = r#"{"type":"invalid","data":"test"}"#;
884 | let result: Result<AuthConfig, _> = serde_json::from_str(json);
885 | assert!(result.is_err(), "Expected error for invalid auth type");
886 | }
887 |
888 | #[test]
889 | fn test_auth_config_missing_fields() {
890 | // Missing username for basic auth
891 | let json = r#"{"type":"basic","password":"testpass"}"#;
892 | let result: Result<AuthConfig, _> = serde_json::from_str(json);
893 | assert!(result.is_err(), "Expected error for missing username");
894 |
895 | // Missing password for basic auth
896 | let json = r#"{"type":"basic","username":"testuser"}"#;
897 | let result: Result<AuthConfig, _> = serde_json::from_str(json);
898 | assert!(result.is_err(), "Expected error for missing password");
899 |
900 | // Missing token for token auth
901 | let json = r#"{"type":"token"}"#;
902 | let result: Result<AuthConfig, _> = serde_json::from_str(json);
903 | assert!(result.is_err(), "Expected error for missing token");
904 | }
905 |
906 | #[test]
907 | fn test_config_with_auths_deserialization() {
908 | let json = r#"
909 | {
910 | "auths": {
911 | "https://api.example.com": {
912 | "type": "basic",
913 | "username": "testuser",
914 | "password": "testpass"
915 | },
916 | "https://secure.api.com": {
917 | "type": "token",
918 | "token": "bearer-token-123"
919 | }
920 | },
921 | "plugins": {
922 | "test_plugin": {
923 | "url": "file:///path/to/plugin"
924 | }
925 | }
926 | }
927 | "#;
928 |
929 | let config: Config = serde_json::from_str(json).unwrap();
930 | assert!(config.auths.is_some());
931 |
932 | let auths = config.auths.unwrap();
933 | assert_eq!(auths.len(), 2);
934 |
935 | let api_url = Url::parse("https://api.example.com").unwrap();
936 | let secure_url = Url::parse("https://secure.api.com").unwrap();
937 |
938 | assert!(auths.contains_key(&api_url));
939 | assert!(auths.contains_key(&secure_url));
940 |
941 | match &auths[&api_url] {
942 | AuthConfig::Basic { username, password } => {
943 | assert_eq!(username, "testuser");
944 | assert_eq!(password, "testpass");
945 | }
946 | _ => panic!("Expected Basic auth for api.example.com"),
947 | }
948 |
949 | match &auths[&secure_url] {
950 | AuthConfig::Token { token } => {
951 | assert_eq!(token, "bearer-token-123");
952 | }
953 | _ => panic!("Expected Token auth for secure.api.com"),
954 | }
955 | }
956 |
957 | #[test]
958 | fn test_config_with_auths_yaml_deserialization() {
959 | let yaml = r#"
960 | auths:
961 | "https://api.example.com":
962 | type: basic
963 | username: testuser
964 | password: testpass
965 | "https://secure.api.com":
966 | type: token
967 | token: bearer-token-123
968 | plugins:
969 | test_plugin:
970 | url: "file:///path/to/plugin"
971 | "#;
972 |
973 | let config: Config = serde_yaml::from_str(yaml).unwrap();
974 | assert!(config.auths.is_some());
975 |
976 | let auths = config.auths.unwrap();
977 | assert_eq!(auths.len(), 2);
978 |
979 | let api_url = Url::parse("https://api.example.com").unwrap();
980 | let secure_url = Url::parse("https://secure.api.com").unwrap();
981 |
982 | assert!(auths.contains_key(&api_url));
983 | assert!(auths.contains_key(&secure_url));
984 | }
985 |
986 | #[test]
987 | fn test_config_without_auths() {
988 | let json = r#"
989 | {
990 | "plugins": {
991 | "test_plugin": {
992 | "url": "file:///path/to/plugin"
993 | }
994 | }
995 | }
996 | "#;
997 |
998 | let config: Config = serde_json::from_str(json).unwrap();
999 | assert!(config.auths.is_none());
1000 | assert_eq!(config.plugins.len(), 1);
1001 | }
1002 |
1003 | #[test]
1004 | fn test_auth_config_clone() {
1005 | let auth_config = AuthConfig::Basic {
1006 | username: "testuser".to_string(),
1007 | password: "testpass".to_string(),
1008 | };
1009 |
1010 | let cloned = auth_config.clone();
1011 | match cloned {
1012 | AuthConfig::Basic { username, password } => {
1013 | assert_eq!(username, "testuser");
1014 | assert_eq!(password, "testpass");
1015 | }
1016 | _ => panic!("Expected Basic auth config"),
1017 | }
1018 | }
1019 |
1020 | #[test]
1021 | fn test_auth_config_debug_format() {
1022 | let auth_config = AuthConfig::Token {
1023 | token: "secret-token".to_string(),
1024 | };
1025 |
1026 | let debug_str = format!("{auth_config:?}");
1027 | assert!(debug_str.contains("Token"));
1028 | assert!(debug_str.contains("secret-token"));
1029 | }
1030 |
1031 | #[test]
1032 | fn test_internal_auth_config_keyring_deserialization() {
1033 | let json = r#"{"type":"keyring","service":"test-service","user":"test-user"}"#;
1034 | let result: Result<InternalAuthConfig, _> = serde_json::from_str(json);
1035 |
1036 | // This should deserialize successfully as InternalAuthConfig
1037 | assert!(result.is_ok());
1038 |
1039 | match result.unwrap() {
1040 | InternalAuthConfig::Keyring { service, user } => {
1041 | assert_eq!(service, "test-service");
1042 | assert_eq!(user, "test-user");
1043 | }
1044 | _ => panic!("Expected Keyring auth config"),
1045 | }
1046 | }
1047 |
1048 | #[test]
1049 | fn test_auth_config_empty_values() {
1050 | // Test with empty username
1051 | let json = r#"{"type":"basic","username":"","password":"testpass"}"#;
1052 | let auth_config: AuthConfig = serde_json::from_str(json).unwrap();
1053 | match auth_config {
1054 | AuthConfig::Basic { username, password } => {
1055 | assert_eq!(username, "");
1056 | assert_eq!(password, "testpass");
1057 | }
1058 | _ => panic!("Expected Basic auth config"),
1059 | }
1060 |
1061 | // Test with empty token
1062 | let json = r#"{"type":"token","token":""}"#;
1063 | let auth_config: AuthConfig = serde_json::from_str(json).unwrap();
1064 | match auth_config {
1065 | AuthConfig::Token { token } => {
1066 | assert_eq!(token, "");
1067 | }
1068 | _ => panic!("Expected Token auth config"),
1069 | }
1070 | }
1071 |
1072 | #[test]
1073 | fn test_load_config_with_auths_yaml() {
1074 | let rt = Runtime::new().unwrap();
1075 | let path = Path::new("tests/fixtures/config_with_auths.yaml");
1076 |
1077 | let cli = Cli {
1078 | config_file: Some(path.to_path_buf()),
1079 |
1080 | ..Default::default()
1081 | };
1082 |
1083 | let config_result = rt.block_on(load_config(&cli));
1084 | assert!(
1085 | config_result.is_ok(),
1086 | "Failed to load config with auths from YAML"
1087 | );
1088 |
1089 | let config = config_result.unwrap();
1090 | assert!(config.auths.is_some(), "Expected auths to be present");
1091 |
1092 | let auths = config.auths.unwrap();
1093 | assert_eq!(auths.len(), 4, "Expected 4 auth configurations");
1094 |
1095 | // Test basic auth
1096 | let api_url = Url::parse("https://api.example.com").unwrap();
1097 | assert!(auths.contains_key(&api_url));
1098 | match &auths[&api_url] {
1099 | AuthConfig::Basic { username, password } => {
1100 | assert_eq!(username, "testuser");
1101 | assert_eq!(password, "testpass");
1102 | }
1103 | _ => panic!("Expected Basic auth for api.example.com"),
1104 | }
1105 |
1106 | // Test token auth
1107 | let secure_url = Url::parse("https://secure.api.com").unwrap();
1108 | assert!(auths.contains_key(&secure_url));
1109 | match &auths[&secure_url] {
1110 | AuthConfig::Token { token } => {
1111 | assert_eq!(token, "bearer-token-123");
1112 | }
1113 | _ => panic!("Expected Token auth for secure.api.com"),
1114 | }
1115 | }
1116 |
1117 | #[test]
1118 | fn test_load_config_with_auths_json() {
1119 | let rt = Runtime::new().unwrap();
1120 | let path = Path::new("tests/fixtures/config_with_auths.json");
1121 |
1122 | let cli = Cli {
1123 | config_file: Some(path.to_path_buf()),
1124 |
1125 | ..Default::default()
1126 | };
1127 |
1128 | let config_result = rt.block_on(load_config(&cli));
1129 | assert!(
1130 | config_result.is_ok(),
1131 | "Failed to load config with auths from JSON"
1132 | );
1133 |
1134 | let config = config_result.unwrap();
1135 | assert!(config.auths.is_some(), "Expected auths to be present");
1136 |
1137 | let auths = config.auths.unwrap();
1138 | assert_eq!(auths.len(), 4, "Expected 4 auth configurations");
1139 |
1140 | // Test that all URLs are present
1141 | let expected_urls = vec![
1142 | "https://api.example.com",
1143 | "https://secure.api.com",
1144 | "https://private.registry.io",
1145 | "https://oauth.service.com",
1146 | ];
1147 |
1148 | for url_str in expected_urls {
1149 | let url = Url::parse(url_str).unwrap();
1150 | assert!(auths.contains_key(&url), "Missing auth for {url_str}");
1151 | }
1152 | }
1153 |
1154 | #[test]
1155 | fn test_load_invalid_auth_config() {
1156 | let rt = Runtime::new().unwrap();
1157 | let path = Path::new("tests/fixtures/invalid_auth_config.yaml");
1158 |
1159 | let cli = Cli {
1160 | config_file: Some(path.to_path_buf()),
1161 |
1162 | ..Default::default()
1163 | };
1164 |
1165 | let config_result = rt.block_on(load_config(&cli));
1166 | assert!(
1167 | config_result.is_err(),
1168 | "Expected error for invalid auth config"
1169 | );
1170 |
1171 | let error = config_result.unwrap_err();
1172 | let error_msg = error.to_string();
1173 | // The error should be related to deserialization
1174 | assert!(
1175 | error_msg.contains("unknown variant")
1176 | || error_msg.contains("missing field")
1177 | || error_msg.contains("invalid"),
1178 | "Error should indicate invalid auth configuration: {error_msg}"
1179 | );
1180 | }
1181 |
1182 | #[test]
1183 | fn test_auth_config_url_matching() {
1184 | let mut auths = HashMap::new();
1185 |
1186 | // Add auth for specific API endpoint
1187 | let api_url = Url::parse("https://api.example.com").unwrap();
1188 | auths.insert(
1189 | api_url,
1190 | AuthConfig::Token {
1191 | token: "api-token".to_string(),
1192 | },
1193 | );
1194 |
1195 | // Add auth for broader domain
1196 | let domain_url = Url::parse("https://example.com").unwrap();
1197 | auths.insert(
1198 | domain_url,
1199 | AuthConfig::Basic {
1200 | username: "user".to_string(),
1201 | password: "pass".to_string(),
1202 | },
1203 | );
1204 |
1205 | let config = Config {
1206 | auths: Some(auths),
1207 | plugins: HashMap::new(),
1208 |
1209 | ..Default::default()
1210 | };
1211 |
1212 | // Serialize and deserialize to test round-trip
1213 | let json = serde_json::to_string(&config).unwrap();
1214 | let deserialized: Config = serde_json::from_str(&json).unwrap();
1215 |
1216 | assert!(deserialized.auths.is_some());
1217 | assert_eq!(deserialized.auths.unwrap().len(), 2);
1218 | }
1219 |
1220 | #[test]
1221 | fn test_auth_config_special_characters() {
1222 | // Test with special characters in passwords and tokens
1223 | let auth_basic = AuthConfig::Basic {
1224 | username: "[email protected]".to_string(),
1225 | password: "p@ssw0rd!#$%".to_string(),
1226 | };
1227 |
1228 | let auth_token = AuthConfig::Token {
1229 | token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ".to_string(),
1230 | };
1231 |
1232 | // Test serialization
1233 | let basic_json = serde_json::to_string(&auth_basic).unwrap();
1234 | let token_json = serde_json::to_string(&auth_token).unwrap();
1235 |
1236 | // Test deserialization
1237 | let basic_deserialized: AuthConfig = serde_json::from_str(&basic_json).unwrap();
1238 | let token_deserialized: AuthConfig = serde_json::from_str(&token_json).unwrap();
1239 |
1240 | match basic_deserialized {
1241 | AuthConfig::Basic { username, password } => {
1242 | assert_eq!(username, "[email protected]");
1243 | assert_eq!(password, "p@ssw0rd!#$%");
1244 | }
1245 | _ => panic!("Expected Basic auth config"),
1246 | }
1247 |
1248 | match token_deserialized {
1249 | AuthConfig::Token { token } => {
1250 | assert!(token.starts_with("eyJ"));
1251 | }
1252 | _ => panic!("Expected Token auth config"),
1253 | }
1254 | }
1255 |
1256 | #[test]
1257 | fn test_config_auths_optional() {
1258 | // Test config without auths field
1259 | let json_without_auths = r#"
1260 | {
1261 | "plugins": {
1262 | "test_plugin": {
1263 | "url": "file:///path/to/plugin"
1264 | }
1265 | }
1266 | }
1267 | "#;
1268 |
1269 | let config: Config = serde_json::from_str(json_without_auths).unwrap();
1270 | assert!(config.auths.is_none());
1271 |
1272 | // Test config with empty auths
1273 | let json_empty_auths = r#"
1274 | {
1275 | "auths": {},
1276 | "plugins": {
1277 | "test_plugin": {
1278 | "url": "file:///path/to/plugin"
1279 | }
1280 | }
1281 | }
1282 | "#;
1283 |
1284 | let config: Config = serde_json::from_str(json_empty_auths).unwrap();
1285 | assert!(config.auths.is_some());
1286 | assert_eq!(config.auths.unwrap().len(), 0);
1287 | }
1288 |
1289 | #[test]
1290 | fn test_keyring_auth_config_deserialization() {
1291 | // Test that keyring config deserializes correctly as InternalAuthConfig
1292 | let json = r#"{"type":"keyring","service":"test-service","user":"test-user"}"#;
1293 | let internal_auth: InternalAuthConfig = serde_json::from_str(json).unwrap();
1294 |
1295 | match internal_auth {
1296 | InternalAuthConfig::Keyring { service, user } => {
1297 | assert_eq!(service, "test-service");
1298 | assert_eq!(user, "test-user");
1299 | }
1300 | _ => panic!("Expected Keyring auth config"),
1301 | }
1302 | }
1303 |
1304 | #[test]
1305 | fn test_documentation_example_yaml() {
1306 | let rt = Runtime::new().unwrap();
1307 | let path = Path::new("tests/fixtures/documentation_example.yaml");
1308 |
1309 | let cli = Cli {
1310 | config_file: Some(path.to_path_buf()),
1311 |
1312 | ..Default::default()
1313 | };
1314 |
1315 | let config_result = rt.block_on(load_config(&cli));
1316 | assert!(
1317 | config_result.is_ok(),
1318 | "Documentation YAML example should be valid"
1319 | );
1320 |
1321 | let config = config_result.unwrap();
1322 |
1323 | // Verify auths are present and correct
1324 | assert!(config.auths.is_some());
1325 | let auths = config.auths.unwrap();
1326 | assert_eq!(
1327 | auths.len(),
1328 | 3,
1329 | "Expected 3 auth configurations from documentation example"
1330 | );
1331 |
1332 | // Verify basic auth
1333 | let registry_url = Url::parse("https://private.registry.io").unwrap();
1334 | match &auths[®istry_url] {
1335 | AuthConfig::Basic { username, password } => {
1336 | assert_eq!(username, "registry-user");
1337 | assert_eq!(password, "registry-pass");
1338 | }
1339 | _ => panic!("Expected Basic auth for private.registry.io"),
1340 | }
1341 |
1342 | // Verify token auth
1343 | let github_url = Url::parse("https://api.github.com").unwrap();
1344 | match &auths[&github_url] {
1345 | AuthConfig::Token { token } => {
1346 | assert_eq!(token, "ghp_1234567890abcdef");
1347 | }
1348 | _ => panic!("Expected Token auth for api.github.com"),
1349 | }
1350 |
1351 | // Verify plugins
1352 | assert_eq!(
1353 | config.plugins.len(),
1354 | 3,
1355 | "Expected 3 plugins from documentation example"
1356 | );
1357 | assert!(config.plugins.contains_key(&PluginName("time".to_string())));
1358 | assert!(config.plugins.contains_key(&PluginName("myip".to_string())));
1359 | assert!(
1360 | config
1361 | .plugins
1362 | .contains_key(&PluginName("private_plugin".to_string()))
1363 | );
1364 |
1365 | // Verify private plugin config
1366 | let private_plugin = &config.plugins[&PluginName("private_plugin".to_string())];
1367 | assert_eq!(
1368 | private_plugin.url.to_string(),
1369 | "https://private.registry.io/my_plugin"
1370 | );
1371 | assert!(private_plugin.runtime_config.is_some());
1372 | }
1373 |
1374 | #[test]
1375 | fn test_documentation_example_json() {
1376 | let rt = Runtime::new().unwrap();
1377 | let path = Path::new("tests/fixtures/documentation_example.json");
1378 |
1379 | let cli = Cli {
1380 | config_file: Some(path.to_path_buf()),
1381 |
1382 | ..Default::default()
1383 | };
1384 |
1385 | let config_result = rt.block_on(load_config(&cli));
1386 | assert!(
1387 | config_result.is_ok(),
1388 | "Documentation JSON example should be valid"
1389 | );
1390 |
1391 | let config = config_result.unwrap();
1392 |
1393 | // Verify auths are present and correct
1394 | assert!(config.auths.is_some());
1395 | let auths = config.auths.unwrap();
1396 | assert_eq!(
1397 | auths.len(),
1398 | 3,
1399 | "Expected 3 auth configurations from documentation example"
1400 | );
1401 |
1402 | // Verify all auth URLs are present
1403 | let expected_auth_urls = vec![
1404 | "https://private.registry.io",
1405 | "https://api.github.com",
1406 | "https://enterprise.api.com",
1407 | ];
1408 |
1409 | for url_str in expected_auth_urls {
1410 | let url = Url::parse(url_str).unwrap();
1411 | assert!(auths.contains_key(&url), "Missing auth for {url_str}");
1412 | }
1413 |
1414 | // Verify plugins match the documentation
1415 | assert_eq!(config.plugins.len(), 3);
1416 |
1417 | let myip_plugin = &config.plugins[&PluginName("myip".to_string())];
1418 | let runtime_config = myip_plugin.runtime_config.as_ref().unwrap();
1419 | assert_eq!(runtime_config.env_vars.as_ref().unwrap()["FOO"], "bar");
1420 | assert_eq!(runtime_config.memory_limit.as_ref().unwrap(), "512Mi");
1421 | }
1422 |
1423 | #[test]
1424 | fn test_url_prefix_matching_from_documentation() {
1425 | // Test the URL matching behavior described in documentation
1426 | let yaml = r#"
1427 | auths:
1428 | "https://example.com":
1429 | type: basic
1430 | username: "broad-user"
1431 | password: "broad-pass"
1432 | "https://example.com/api":
1433 | type: token
1434 | token: "api-token"
1435 | "https://example.com/api/v1":
1436 | type: basic
1437 | username: "v1-user"
1438 | password: "v1-pass"
1439 | plugins:
1440 | test_plugin:
1441 | url: "file:///test"
1442 | "#;
1443 |
1444 | let config: Config = serde_yaml::from_str(yaml).unwrap();
1445 | assert!(config.auths.is_some());
1446 |
1447 | let auths = config.auths.unwrap();
1448 | assert_eq!(auths.len(), 3);
1449 |
1450 | // Verify all three auth configs are present
1451 | let base_url = Url::parse("https://example.com").unwrap();
1452 | let api_url = Url::parse("https://example.com/api").unwrap();
1453 | let v1_url = Url::parse("https://example.com/api/v1").unwrap();
1454 |
1455 | assert!(auths.contains_key(&base_url));
1456 | assert!(auths.contains_key(&api_url));
1457 | assert!(auths.contains_key(&v1_url));
1458 |
1459 | // Verify the specific auth types match documentation
1460 | match &auths[&base_url] {
1461 | AuthConfig::Basic { username, .. } => {
1462 | assert_eq!(username, "broad-user");
1463 | }
1464 | _ => panic!("Expected Basic auth for base URL"),
1465 | }
1466 |
1467 | match &auths[&api_url] {
1468 | AuthConfig::Token { token } => {
1469 | assert_eq!(token, "api-token");
1470 | }
1471 | _ => panic!("Expected Token auth for API URL"),
1472 | }
1473 |
1474 | match &auths[&v1_url] {
1475 | AuthConfig::Basic { username, .. } => {
1476 | assert_eq!(username, "v1-user");
1477 | }
1478 | _ => panic!("Expected Basic auth for v1 URL"),
1479 | }
1480 | }
1481 |
1482 | #[test]
1483 | fn test_keyring_json_format_validation() {
1484 | // Test that the JSON formats shown in keyring documentation examples are valid
1485 |
1486 | // Test basic auth JSON format from documentation
1487 | let basic_json = r#"{"type":"basic","username":"actual-user","password":"actual-pass"}"#;
1488 | let basic_auth: AuthConfig = serde_json::from_str(basic_json).unwrap();
1489 |
1490 | match basic_auth {
1491 | AuthConfig::Basic { username, password } => {
1492 | assert_eq!(username, "actual-user");
1493 | assert_eq!(password, "actual-pass");
1494 | }
1495 | _ => panic!("Expected Basic auth config from keyring JSON"),
1496 | }
1497 |
1498 | // Test token auth JSON format from documentation
1499 | let token_json = r#"{"type":"token","token":"actual-bearer-token"}"#;
1500 | let token_auth: AuthConfig = serde_json::from_str(token_json).unwrap();
1501 |
1502 | match token_auth {
1503 | AuthConfig::Token { token } => {
1504 | assert_eq!(token, "actual-bearer-token");
1505 | }
1506 | _ => panic!("Expected Token auth config from keyring JSON"),
1507 | }
1508 |
1509 | // Test JWT-like token from documentation
1510 | let jwt_json = r#"{"type":"token","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"}"#;
1511 | let jwt_auth: AuthConfig = serde_json::from_str(jwt_json).unwrap();
1512 |
1513 | match jwt_auth {
1514 | AuthConfig::Token { token } => {
1515 | assert_eq!(token, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9");
1516 | }
1517 | _ => panic!("Expected Token auth config from keyring JWT JSON"),
1518 | }
1519 |
1520 | // Test corporate example from documentation
1521 | let corp_json = r#"{"type":"basic","username":"corp_user","password":"corp_secret"}"#;
1522 | let corp_auth: AuthConfig = serde_json::from_str(corp_json).unwrap();
1523 |
1524 | match corp_auth {
1525 | AuthConfig::Basic { username, password } => {
1526 | assert_eq!(username, "corp_user");
1527 | assert_eq!(password, "corp_secret");
1528 | }
1529 | _ => panic!("Expected Basic auth config from corporate JSON"),
1530 | }
1531 | }
1532 |
1533 | #[test]
1534 | #[ignore] // Requires system keyring access - run with `cargo test -- --ignored`
1535 | fn test_keyring_auth_integration() {
1536 | use std::process::Command;
1537 | use std::time::{SystemTime, UNIX_EPOCH};
1538 |
1539 | // Generate unique service and user names to avoid conflicts
1540 | let timestamp = SystemTime::now()
1541 | .duration_since(UNIX_EPOCH)
1542 | .unwrap()
1543 | .as_secs();
1544 | let service_name = format!("hyper-mcp-test-{timestamp}");
1545 | let user_name = format!("test-user-{timestamp}");
1546 |
1547 | // Test auth config to store in keyring
1548 | let test_auth_json =
1549 | r#"{"type":"basic","username":"keyring-test-user","password":"keyring-test-pass"}"#;
1550 |
1551 | // Platform-specific keyring operations
1552 | let (add_result, remove_result) = if cfg!(target_os = "macos") {
1553 | // macOS using security command
1554 | let add_result = Command::new("security")
1555 | .args([
1556 | "add-generic-password",
1557 | "-a",
1558 | &user_name,
1559 | "-s",
1560 | &service_name,
1561 | "-w",
1562 | test_auth_json,
1563 | ])
1564 | .output();
1565 |
1566 | let remove_result = Command::new("security")
1567 | .args([
1568 | "delete-generic-password",
1569 | "-a",
1570 | &user_name,
1571 | "-s",
1572 | &service_name,
1573 | ])
1574 | .output();
1575 |
1576 | (add_result, remove_result)
1577 | } else if cfg!(target_os = "linux") {
1578 | // Linux using secret-tool
1579 | let add_result = Command::new("bash")
1580 | .args([
1581 | "-c",
1582 | &format!("echo '{test_auth_json}' | secret-tool store --label='hyper-mcp test' service '{service_name}' username '{user_name}'"),
1583 | ])
1584 | .output();
1585 |
1586 | let remove_result = Command::new("secret-tool")
1587 | .args(["clear", "service", &service_name, "username", &user_name])
1588 | .output();
1589 |
1590 | (add_result, remove_result)
1591 | } else if cfg!(target_os = "windows") {
1592 | // Windows using cmdkey
1593 | let escaped_json = test_auth_json.replace("\"", "\\\"");
1594 | let add_result = Command::new("cmdkey")
1595 | .args([
1596 | &format!("/generic:{service_name}"),
1597 | &format!("/user:{user_name}"),
1598 | &format!("/pass:{escaped_json}"),
1599 | ])
1600 | .output();
1601 |
1602 | let remove_result = Command::new("cmdkey")
1603 | .args([&format!("/delete:{service_name}")])
1604 | .output();
1605 |
1606 | (add_result, remove_result)
1607 | } else {
1608 | // Unsupported platform
1609 | println!(
1610 | "Keyring test skipped on unsupported platform: {}",
1611 | std::env::consts::OS
1612 | );
1613 | return;
1614 | };
1615 |
1616 | // Try to add the secret to keyring
1617 | let add_output = match add_result {
1618 | Ok(output) => output,
1619 | Err(e) => {
1620 | println!("Failed to execute keyring add command: {e}. Skipping test.");
1621 | return;
1622 | }
1623 | };
1624 |
1625 | if !add_output.status.success() {
1626 | println!(
1627 | "Failed to add secret to keyring (exit code: {}). stdout: {}, stderr: {}. Skipping test.",
1628 | add_output.status.code().unwrap_or(-1),
1629 | String::from_utf8_lossy(&add_output.stdout),
1630 | String::from_utf8_lossy(&add_output.stderr)
1631 | );
1632 | return;
1633 | }
1634 |
1635 | // Test keyring auth deserialization
1636 | let keyring_config_json =
1637 | format!(r#"{{"type":"keyring","service":"{service_name}","user":"{user_name}"}}"#);
1638 |
1639 | let test_result = std::panic::catch_unwind(|| {
1640 | let internal_auth: InternalAuthConfig =
1641 | serde_json::from_str(&keyring_config_json).unwrap();
1642 |
1643 | // This should trigger the keyring lookup and deserialize to AuthConfig
1644 | match internal_auth {
1645 | InternalAuthConfig::Keyring { service, user } => {
1646 | assert_eq!(service, service_name);
1647 | assert_eq!(user, user_name);
1648 |
1649 | // Test the actual keyring deserialization through AuthConfig
1650 | let auth_config: Result<AuthConfig, _> =
1651 | serde_json::from_str(&keyring_config_json);
1652 |
1653 | match auth_config {
1654 | Ok(AuthConfig::Basic { username, password }) => {
1655 | assert_eq!(username, "keyring-test-user");
1656 | assert_eq!(password, "keyring-test-pass");
1657 | }
1658 | Ok(AuthConfig::Token { .. }) => {
1659 | panic!("Expected Basic auth from keyring, got Token");
1660 | }
1661 | Err(e) => {
1662 | println!(
1663 | "Keyring lookup failed (this is expected if keyring service is not available): {e}"
1664 | );
1665 | }
1666 | }
1667 | }
1668 | _ => panic!("Expected Keyring internal auth config"),
1669 | }
1670 | });
1671 |
1672 | // Always attempt cleanup regardless of test result
1673 | if let Ok(output) = remove_result {
1674 | if !output.status.success() {
1675 | println!(
1676 | "Warning: Failed to remove test secret from keyring (exit code: {}). stdout: {}, stderr: {}",
1677 | output.status.code().unwrap_or(-1),
1678 | String::from_utf8_lossy(&output.stdout),
1679 | String::from_utf8_lossy(&output.stderr)
1680 | );
1681 | }
1682 | }
1683 |
1684 | // Re-panic if the test failed
1685 | if let Err(panic_info) = test_result {
1686 | std::panic::resume_unwind(panic_info);
1687 | }
1688 | }
1689 |
1690 | #[test]
1691 | #[ignore] // Requires system keyring access and file creation - run with `cargo test -- --ignored`
1692 | fn test_keyring_auth_complete_config_integration() {
1693 | use std::process::Command;
1694 | use std::time::{SystemTime, UNIX_EPOCH};
1695 | use tokio::fs;
1696 |
1697 | let rt = Runtime::new().unwrap();
1698 |
1699 | // Generate unique identifiers
1700 | let timestamp = SystemTime::now()
1701 | .duration_since(UNIX_EPOCH)
1702 | .unwrap()
1703 | .as_secs();
1704 | let service_name = format!("hyper-mcp-config-test-{timestamp}");
1705 | let user_name = format!("config-test-user-{timestamp}");
1706 | let temp_config_path = format!("test_config_{timestamp}.yaml");
1707 |
1708 | // Auth config to store in keyring
1709 | let keyring_auth_json =
1710 | r#"{"type":"token","token":"test-keyring-token-from-complete-config"}"#;
1711 |
1712 | // Create complete config with keyring auth
1713 | let config_content = format!(
1714 | r#"
1715 | auths:
1716 | "https://keyring-test.example.com":
1717 | type: keyring
1718 | service: "{service_name}"
1719 | user: "{user_name}"
1720 | "https://basic-test.example.com":
1721 | type: basic
1722 | username: "basic-user"
1723 | password: "basic-pass"
1724 | plugins:
1725 | test_plugin:
1726 | url: "file:///test/plugin"
1727 | runtime_config:
1728 | allowed_hosts:
1729 | - "keyring-test.example.com"
1730 | - "basic-test.example.com"
1731 | "#
1732 | );
1733 |
1734 | // Platform-specific keyring operations
1735 | let (add_result, remove_result) = if cfg!(target_os = "macos") {
1736 | let add_result = Command::new("security")
1737 | .args([
1738 | "add-generic-password",
1739 | "-a",
1740 | &user_name,
1741 | "-s",
1742 | &service_name,
1743 | "-w",
1744 | keyring_auth_json,
1745 | ])
1746 | .output();
1747 |
1748 | let remove_result = Command::new("security")
1749 | .args([
1750 | "delete-generic-password",
1751 | "-a",
1752 | &user_name,
1753 | "-s",
1754 | &service_name,
1755 | ])
1756 | .output();
1757 |
1758 | (add_result, remove_result)
1759 | } else if cfg!(target_os = "linux") {
1760 | let add_result = Command::new("bash")
1761 | .args([
1762 | "-c",
1763 | &format!(
1764 | "echo '{keyring_auth_json}' | secret-tool store --label='hyper-mcp complete config test' service '{service_name}' username '{user_name}'"
1765 | ),
1766 | ])
1767 | .output();
1768 |
1769 | let remove_result = Command::new("secret-tool")
1770 | .args(["clear", "service", &service_name, "username", &user_name])
1771 | .output();
1772 |
1773 | (add_result, remove_result)
1774 | } else if cfg!(target_os = "windows") {
1775 | let escaped_json = keyring_auth_json.replace("\"", "\\\"");
1776 | let add_result = Command::new("cmdkey")
1777 | .args([
1778 | &format!("/generic:{service_name}"),
1779 | &format!("/user:{user_name}"),
1780 | &format!("/pass:{escaped_json}"),
1781 | ])
1782 | .output();
1783 |
1784 | let remove_result = Command::new("cmdkey")
1785 | .args([&format!("/delete:{service_name}")])
1786 | .output();
1787 |
1788 | (add_result, remove_result)
1789 | } else {
1790 | println!(
1791 | "Keyring integration test skipped on unsupported platform: {}",
1792 | std::env::consts::OS
1793 | );
1794 | return;
1795 | };
1796 |
1797 | // Create temporary config file
1798 | let config_path = Path::new(&temp_config_path);
1799 | let write_result = rt.block_on(fs::write(config_path, config_content));
1800 | if write_result.is_err() {
1801 | println!("Failed to create temporary config file. Skipping test.");
1802 | return;
1803 | }
1804 |
1805 | // Try to add secret to keyring
1806 | let add_output = match add_result {
1807 | Ok(output) => output,
1808 | Err(e) => {
1809 | println!("Failed to execute keyring add command: {e}. Skipping test.");
1810 | let _ = rt.block_on(fs::remove_file(config_path));
1811 | return;
1812 | }
1813 | };
1814 |
1815 | if !add_output.status.success() {
1816 | println!(
1817 | "Failed to add secret to keyring (exit code: {}). stdout: {}, stderr: {}. Skipping test.",
1818 | add_output.status.code().unwrap_or(-1),
1819 | String::from_utf8_lossy(&add_output.stdout),
1820 | String::from_utf8_lossy(&add_output.stderr)
1821 | );
1822 | let _ = rt.block_on(fs::remove_file(config_path));
1823 | return;
1824 | }
1825 |
1826 | let cli = Cli {
1827 | config_file: Some(config_path.to_path_buf()),
1828 |
1829 | ..Default::default()
1830 | };
1831 |
1832 | // Test loading the config file (this should trigger keyring lookup)
1833 | let load_result = rt.block_on(load_config(&cli));
1834 |
1835 | // Cleanup keyring entry before checking results
1836 | if let Ok(output) = remove_result {
1837 | if !output.status.success() {
1838 | println!(
1839 | "Warning: Failed to remove test secret from keyring (exit code: {}). stdout: {}, stderr: {}. Manual cleanup may be required.",
1840 | output.status.code().unwrap_or(-1),
1841 | String::from_utf8_lossy(&output.stdout),
1842 | String::from_utf8_lossy(&output.stderr)
1843 | );
1844 | }
1845 | }
1846 |
1847 | // Cleanup temporary config file
1848 | let _ = rt.block_on(fs::remove_file(config_path));
1849 |
1850 | // Now check the test results
1851 | match load_result {
1852 | Ok(config) => {
1853 | // Verify auths are present
1854 | assert!(
1855 | config.auths.is_some(),
1856 | "Expected auths to be present in loaded config"
1857 | );
1858 | let auths = config.auths.unwrap();
1859 | assert_eq!(auths.len(), 2, "Expected 2 auth configurations");
1860 |
1861 | // Verify keyring auth was resolved successfully
1862 | let keyring_url = Url::parse("https://keyring-test.example.com").unwrap();
1863 | assert!(
1864 | auths.contains_key(&keyring_url),
1865 | "Expected keyring auth URL to be present"
1866 | );
1867 |
1868 | match &auths[&keyring_url] {
1869 | AuthConfig::Token { token } => {
1870 | assert_eq!(
1871 | token, "test-keyring-token-from-complete-config",
1872 | "Token from keyring should match stored value"
1873 | );
1874 | }
1875 | _ => panic!("Expected Token auth from keyring resolution"),
1876 | }
1877 |
1878 | // Verify basic auth still works alongside keyring auth
1879 | let basic_url = Url::parse("https://basic-test.example.com").unwrap();
1880 | assert!(
1881 | auths.contains_key(&basic_url),
1882 | "Expected basic auth URL to be present"
1883 | );
1884 |
1885 | match &auths[&basic_url] {
1886 | AuthConfig::Basic { username, password } => {
1887 | assert_eq!(username, "basic-user");
1888 | assert_eq!(password, "basic-pass");
1889 | }
1890 | _ => panic!("Expected Basic auth config"),
1891 | }
1892 |
1893 | // Verify plugins loaded correctly
1894 | assert_eq!(config.plugins.len(), 1, "Expected 1 plugin in config");
1895 | assert!(
1896 | config
1897 | .plugins
1898 | .contains_key(&PluginName("test_plugin".to_string()))
1899 | );
1900 |
1901 | println!(
1902 | "✅ Keyring integration test passed on platform: {}",
1903 | std::env::consts::OS
1904 | );
1905 | }
1906 | Err(e) => {
1907 | // Check if this is a keyring-related error
1908 | let error_msg = e.to_string();
1909 | if error_msg.contains("keyring") || error_msg.contains("secure storage") {
1910 | println!(
1911 | "Keyring lookup failed (keyring service may not be available): {e}. This is acceptable for CI environments."
1912 | );
1913 | } else {
1914 | panic!("Unexpected error loading config with keyring auth: {e}");
1915 | }
1916 | }
1917 | }
1918 | }
1919 |
1920 | #[test]
1921 | #[ignore] // Requires system keyring access - run with `cargo test -- --ignored`
1922 | fn test_keyring_auth_direct_deserialization() {
1923 | use std::process::Command;
1924 | use std::time::{SystemTime, UNIX_EPOCH};
1925 |
1926 | // Generate unique service and user names to avoid conflicts
1927 | let timestamp = SystemTime::now()
1928 | .duration_since(UNIX_EPOCH)
1929 | .unwrap()
1930 | .as_secs();
1931 | let service_name = format!("hyper-mcp-direct-test-{timestamp}");
1932 | let user_name = format!("direct-test-user-{timestamp}");
1933 |
1934 | // Test auth config to store in keyring (basic auth this time)
1935 | let test_auth_json =
1936 | r#"{"type":"basic","username":"direct-keyring-user","password":"direct-keyring-pass"}"#;
1937 |
1938 | // Determine platform and execute appropriate keyring commands
1939 | if cfg!(target_os = "macos") {
1940 | // macOS: Add and test, then cleanup
1941 | let add_cmd = Command::new("security")
1942 | .args([
1943 | "add-generic-password",
1944 | "-a",
1945 | &user_name,
1946 | "-s",
1947 | &service_name,
1948 | "-w",
1949 | test_auth_json,
1950 | ])
1951 | .output();
1952 |
1953 | if let Ok(add_output) = add_cmd {
1954 | if add_output.status.success() {
1955 | // Test the keyring deserialization
1956 | let keyring_config_json = format!(
1957 | r#"{{"type":"keyring","service":"{service_name}","user":"{user_name}"}}"#
1958 | );
1959 |
1960 | let auth_result: Result<AuthConfig, _> =
1961 | serde_json::from_str(&keyring_config_json);
1962 |
1963 | // Cleanup first
1964 | let _ = Command::new("security")
1965 | .args([
1966 | "delete-generic-password",
1967 | "-a",
1968 | &user_name,
1969 | "-s",
1970 | &service_name,
1971 | ])
1972 | .output();
1973 |
1974 | // Verify result
1975 | match auth_result {
1976 | Ok(AuthConfig::Basic { username, password }) => {
1977 | assert_eq!(username, "direct-keyring-user");
1978 | assert_eq!(password, "direct-keyring-pass");
1979 | println!("✅ macOS keyring direct deserialization test passed");
1980 | }
1981 | Ok(_) => panic!("Expected Basic auth from keyring"),
1982 | Err(e) => {
1983 | println!(
1984 | "Keyring lookup failed on macOS (may not be available in CI): {e}"
1985 | );
1986 | }
1987 | }
1988 | } else {
1989 | println!("Failed to add secret to macOS keyring, skipping test");
1990 | }
1991 | }
1992 | } else if cfg!(target_os = "linux") {
1993 | // Linux: Add and test, then cleanup
1994 | let add_cmd = Command::new("bash")
1995 | .args([
1996 | "-c",
1997 | &format!(
1998 | "echo '{test_auth_json}' | secret-tool store --label='hyper-mcp direct test' service '{service_name}' username '{user_name}'"
1999 | ),
2000 | ])
2001 | .output();
2002 |
2003 | if let Ok(add_output) = add_cmd {
2004 | if add_output.status.success() {
2005 | // Test the keyring deserialization
2006 | let keyring_config_json = format!(
2007 | r#"{{"type":"keyring","service":"{service_name}","user":"{user_name}"}}"#
2008 | );
2009 |
2010 | let auth_result: Result<AuthConfig, _> =
2011 | serde_json::from_str(&keyring_config_json);
2012 |
2013 | // Cleanup first
2014 | let _ = Command::new("secret-tool")
2015 | .args(["clear", "service", &service_name, "username", &user_name])
2016 | .output();
2017 |
2018 | // Verify result
2019 | match auth_result {
2020 | Ok(AuthConfig::Basic { username, password }) => {
2021 | assert_eq!(username, "direct-keyring-user");
2022 | assert_eq!(password, "direct-keyring-pass");
2023 | println!("✅ Linux keyring direct deserialization test passed");
2024 | }
2025 | Ok(_) => panic!("Expected Basic auth from keyring"),
2026 | Err(e) => {
2027 | println!(
2028 | "Keyring lookup failed on Linux (may not be available in CI): {e}"
2029 | );
2030 | }
2031 | }
2032 | } else {
2033 | println!("Failed to add secret to Linux keyring, skipping test");
2034 | }
2035 | }
2036 | } else if cfg!(target_os = "windows") {
2037 | // Windows: Add and test, then cleanup
2038 | let escaped_json = test_auth_json.replace("\"", "\\\"");
2039 | let add_cmd = Command::new("cmdkey")
2040 | .args([
2041 | &format!("/generic:{service_name}"),
2042 | &format!("/user:{user_name}"),
2043 | &format!("/pass:{escaped_json}"),
2044 | ])
2045 | .output();
2046 |
2047 | if let Ok(add_output) = add_cmd {
2048 | if add_output.status.success() {
2049 | // Test the keyring deserialization
2050 | let keyring_config_json = format!(
2051 | r#"{{"type":"keyring","service":"{service_name}","user":"{user_name}"}}"#
2052 | );
2053 |
2054 | let auth_result: Result<AuthConfig, _> =
2055 | serde_json::from_str(&keyring_config_json);
2056 |
2057 | // Cleanup first
2058 | let _ = Command::new("cmdkey")
2059 | .args([&format!("/delete:{service_name}")])
2060 | .output();
2061 |
2062 | // Verify result
2063 | match auth_result {
2064 | Ok(AuthConfig::Basic { username, password }) => {
2065 | assert_eq!(username, "direct-keyring-user");
2066 | assert_eq!(password, "direct-keyring-pass");
2067 | println!("✅ Windows keyring direct deserialization test passed");
2068 | }
2069 | Ok(_) => panic!("Expected Basic auth from keyring"),
2070 | Err(e) => {
2071 | println!(
2072 | "Keyring lookup failed on Windows (may not be available in CI): {e}"
2073 | );
2074 | }
2075 | }
2076 | } else {
2077 | println!("Failed to add secret to Windows keyring, skipping test");
2078 | }
2079 | }
2080 | } else {
2081 | println!(
2082 | "Direct keyring deserialization test skipped on unsupported platform: {}",
2083 | std::env::consts::OS
2084 | );
2085 | }
2086 | }
2087 |
2088 | #[test]
2089 | fn test_platform_detection_and_keyring_tool_availability() {
2090 | use std::process::Command;
2091 |
2092 | println!(
2093 | "Running platform detection test on: {}",
2094 | std::env::consts::OS
2095 | );
2096 |
2097 | if cfg!(target_os = "macos") {
2098 | // Test macOS security command availability
2099 | let security_check = Command::new("security").arg("help").output();
2100 |
2101 | match security_check {
2102 | Ok(output) => {
2103 | if output.status.success() {
2104 | println!("✅ macOS security command is available");
2105 |
2106 | // Test that we can list keychains (read-only operation)
2107 | let list_check = Command::new("security").args(["list-keychains"]).output();
2108 | match list_check {
2109 | Ok(list_output) if list_output.status.success() => {
2110 | println!("✅ macOS keychain access is functional");
2111 | }
2112 | _ => {
2113 | println!("⚠️ macOS keychain access may be limited");
2114 | }
2115 | }
2116 | } else {
2117 | println!("❌ macOS security command failed");
2118 | }
2119 | }
2120 | Err(e) => {
2121 | println!("❌ macOS security command not found: {e}");
2122 | }
2123 | }
2124 | } else if cfg!(target_os = "linux") {
2125 | // Test Linux secret-tool availability
2126 | let secret_tool_check = Command::new("secret-tool").arg("--help").output();
2127 |
2128 | match secret_tool_check {
2129 | Ok(output) => {
2130 | if output.status.success() {
2131 | println!("✅ Linux secret-tool is available");
2132 | } else {
2133 | println!("❌ Linux secret-tool command failed");
2134 | }
2135 | }
2136 | Err(e) => {
2137 | println!(
2138 | "❌ Linux secret-tool not found: {e}. Install with: sudo apt-get install libsecret-tools"
2139 | );
2140 | }
2141 | }
2142 |
2143 | // Check if dbus session is available (required for keyring)
2144 | let dbus_check = Command::new("dbus-send")
2145 | .args([
2146 | "--session",
2147 | "--dest=org.freedesktop.DBus",
2148 | "--print-reply",
2149 | "/org/freedesktop/DBus",
2150 | "org.freedesktop.DBus.ListNames",
2151 | ])
2152 | .output();
2153 |
2154 | match dbus_check {
2155 | Ok(output) if output.status.success() => {
2156 | println!("✅ Linux D-Bus session is available");
2157 | }
2158 | _ => {
2159 | println!("⚠️ Linux D-Bus session may not be available (required for keyring)");
2160 | }
2161 | }
2162 | } else if cfg!(target_os = "windows") {
2163 | // Test Windows cmdkey availability
2164 | let cmdkey_check = Command::new("cmdkey").arg("/?").output();
2165 |
2166 | match cmdkey_check {
2167 | Ok(output) => {
2168 | if output.status.success() {
2169 | println!("✅ Windows cmdkey is available");
2170 |
2171 | // Test that we can list credentials (read-only operation)
2172 | let list_check = Command::new("cmdkey").args(["/list"]).output();
2173 | match list_check {
2174 | Ok(list_output) if list_output.status.success() => {
2175 | println!("✅ Windows Credential Manager access is functional");
2176 | }
2177 | _ => {
2178 | println!("⚠️ Windows Credential Manager access may be limited");
2179 | }
2180 | }
2181 | } else {
2182 | println!("❌ Windows cmdkey command failed");
2183 | }
2184 | }
2185 | Err(e) => {
2186 | println!("❌ Windows cmdkey not found: {e}");
2187 | }
2188 | }
2189 | } else {
2190 | println!(
2191 | "ℹ️ Platform {} is not supported for keyring authentication",
2192 | std::env::consts::OS
2193 | );
2194 | }
2195 | }
2196 |
2197 | #[test]
2198 | fn test_keyring_auth_config_missing_service() {
2199 | let json = r#"{"type":"keyring","user":"test-user"}"#;
2200 | let result: Result<InternalAuthConfig, _> = serde_json::from_str(json);
2201 | assert!(result.is_err(), "Expected error for missing service field");
2202 | }
2203 |
2204 | #[test]
2205 | fn test_keyring_auth_config_missing_user() {
2206 | let json = r#"{"type":"keyring","service":"test-service"}"#;
2207 | let result: Result<InternalAuthConfig, _> = serde_json::from_str(json);
2208 | assert!(result.is_err(), "Expected error for missing user field");
2209 | }
2210 |
2211 | #[test]
2212 | fn test_keyring_auth_config_empty_values() {
2213 | let json = r#"{"type":"keyring","service":"","user":"test-user"}"#;
2214 | let internal_auth: InternalAuthConfig = serde_json::from_str(json).unwrap();
2215 |
2216 | match internal_auth {
2217 | InternalAuthConfig::Keyring { service, user } => {
2218 | assert_eq!(service, "");
2219 | assert_eq!(user, "test-user");
2220 | }
2221 | _ => panic!("Expected Keyring auth config"),
2222 | }
2223 | }
2224 |
2225 | #[test]
2226 | fn test_mixed_auth_types_config() {
2227 | let json = r#"
2228 | {
2229 | "auths": {
2230 | "https://basic.example.com": {
2231 | "type": "basic",
2232 | "username": "basicuser",
2233 | "password": "basicpass"
2234 | },
2235 | "https://token.example.com": {
2236 | "type": "token",
2237 | "token": "token-123"
2238 | }
2239 | },
2240 | "plugins": {
2241 | "test_plugin": {
2242 | "url": "file:///path/to/plugin"
2243 | }
2244 | }
2245 | }
2246 | "#;
2247 |
2248 | let config: Config = serde_json::from_str(json).unwrap();
2249 | assert!(config.auths.is_some());
2250 |
2251 | let auths = config.auths.unwrap();
2252 | assert_eq!(auths.len(), 2);
2253 |
2254 | // Verify we have both auth types
2255 | let basic_url = Url::parse("https://basic.example.com").unwrap();
2256 | let token_url = Url::parse("https://token.example.com").unwrap();
2257 |
2258 | match &auths[&basic_url] {
2259 | AuthConfig::Basic { username, password } => {
2260 | assert_eq!(username, "basicuser");
2261 | assert_eq!(password, "basicpass");
2262 | }
2263 | _ => panic!("Expected Basic auth"),
2264 | }
2265 |
2266 | match &auths[&token_url] {
2267 | AuthConfig::Token { token } => {
2268 | assert_eq!(token, "token-123");
2269 | }
2270 | _ => panic!("Expected Token auth"),
2271 | }
2272 | }
2273 |
2274 | #[test]
2275 | fn test_auth_config_yaml_mixed_types() {
2276 | let yaml = r#"
2277 | auths:
2278 | "https://basic.example.com":
2279 | type: basic
2280 | username: basicuser
2281 | password: basicpass
2282 | "https://token.example.com":
2283 | type: token
2284 | token: token-123
2285 | plugins:
2286 | test_plugin:
2287 | url: "file:///path/to/plugin"
2288 | "#;
2289 |
2290 | let config: Config = serde_yaml::from_str(yaml).unwrap();
2291 | assert!(config.auths.is_some());
2292 |
2293 | let auths = config.auths.unwrap();
2294 | assert_eq!(auths.len(), 2);
2295 | }
2296 |
2297 | #[test]
2298 | fn test_auth_config_special_urls() {
2299 | let mut auths = HashMap::new();
2300 |
2301 | // Test with localhost URL
2302 | let localhost_url = Url::parse("http://localhost:8080").unwrap();
2303 | auths.insert(
2304 | localhost_url.clone(),
2305 | AuthConfig::Basic {
2306 | username: "localuser".to_string(),
2307 | password: "localpass".to_string(),
2308 | },
2309 | );
2310 |
2311 | // Test with IP address URL
2312 | let ip_url = Url::parse("https://192.168.1.100:443").unwrap();
2313 | auths.insert(
2314 | ip_url.clone(),
2315 | AuthConfig::Token {
2316 | token: "ip-token".to_string(),
2317 | },
2318 | );
2319 |
2320 | // Test with custom port
2321 | let custom_port_url = Url::parse("https://api.example.com:9000").unwrap();
2322 | auths.insert(
2323 | custom_port_url.clone(),
2324 | AuthConfig::Basic {
2325 | username: "portuser".to_string(),
2326 | password: "portpass".to_string(),
2327 | },
2328 | );
2329 |
2330 | let config = Config {
2331 | auths: Some(auths),
2332 | plugins: HashMap::new(),
2333 |
2334 | ..Default::default()
2335 | };
2336 |
2337 | // Test serialization and deserialization round-trip
2338 | let json = serde_json::to_string(&config).unwrap();
2339 | let deserialized: Config = serde_json::from_str(&json).unwrap();
2340 |
2341 | assert!(deserialized.auths.is_some());
2342 | let deserialized_auths = deserialized.auths.unwrap();
2343 | assert_eq!(deserialized_auths.len(), 3);
2344 |
2345 | assert!(deserialized_auths.contains_key(&localhost_url));
2346 | assert!(deserialized_auths.contains_key(&ip_url));
2347 | assert!(deserialized_auths.contains_key(&custom_port_url));
2348 | }
2349 |
2350 | #[test]
2351 | fn test_auth_config_unicode_values() {
2352 | // Test with unicode characters in credentials
2353 | let auth_config = AuthConfig::Basic {
2354 | username: "用户名".to_string(),
2355 | password: "密码🔐".to_string(),
2356 | };
2357 |
2358 | let json = serde_json::to_string(&auth_config).unwrap();
2359 | let deserialized: AuthConfig = serde_json::from_str(&json).unwrap();
2360 |
2361 | match deserialized {
2362 | AuthConfig::Basic { username, password } => {
2363 | assert_eq!(username, "用户名");
2364 | assert_eq!(password, "密码🔐");
2365 | }
2366 | _ => panic!("Expected Basic auth config"),
2367 | }
2368 | }
2369 |
2370 | #[test]
2371 | fn test_auth_config_long_token() {
2372 | // Test with very long token (JWT-like)
2373 | let long_token = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE2NzAyODYyNjMifQ.eyJhdWQiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsImV4cCI6MTYzNzI4NjI2MywiaWF0IjoxNjM3Mjc5MDYzLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIn0.signature_here_would_be_much_longer";
2374 |
2375 | let auth_config = AuthConfig::Token {
2376 | token: long_token.to_string(),
2377 | };
2378 |
2379 | let json = serde_json::to_string(&auth_config).unwrap();
2380 | let deserialized: AuthConfig = serde_json::from_str(&json).unwrap();
2381 |
2382 | match deserialized {
2383 | AuthConfig::Token { token } => {
2384 | assert_eq!(token, long_token);
2385 | assert!(token.len() > 200);
2386 | }
2387 | _ => panic!("Expected Token auth config"),
2388 | }
2389 | }
2390 |
2391 | // Tests for skip_tools Option<RegexSet> functionality
2392 | #[test]
2393 | fn test_skip_tools_none() {
2394 | let runtime_config = RuntimeConfig {
2395 | skip_prompts: None,
2396 | skip_resource_templates: None,
2397 | skip_resources: None,
2398 | skip_tools: None,
2399 | allowed_hosts: None,
2400 | allowed_paths: None,
2401 | env_vars: None,
2402 | memory_limit: None,
2403 | };
2404 |
2405 | // Test serialization
2406 | let json = serde_json::to_string(&runtime_config).unwrap();
2407 | assert!(json.contains("\"skip_tools\":null"));
2408 |
2409 | // Test deserialization
2410 | let deserialized: RuntimeConfig = serde_json::from_str(&json).unwrap();
2411 | assert!(deserialized.skip_tools.is_none());
2412 | }
2413 |
2414 | #[test]
2415 | fn test_skip_tools_some_basic() {
2416 | let json = r#"{
2417 | "skip_tools": ["tool1", "tool2", "tool3"]
2418 | }"#;
2419 |
2420 | let runtime_config: RuntimeConfig = serde_json::from_str(json).unwrap();
2421 | let skip_tools = runtime_config.skip_tools.as_ref().unwrap();
2422 |
2423 | assert_eq!(skip_tools.len(), 3);
2424 | assert!(skip_tools.is_match("tool1"));
2425 | assert!(skip_tools.is_match("tool2"));
2426 | assert!(skip_tools.is_match("tool3"));
2427 | assert!(!skip_tools.is_match("tool4"));
2428 | assert!(!skip_tools.is_match("tool1_extended"));
2429 | }
2430 |
2431 | #[test]
2432 | fn test_skip_tools_regex_patterns() {
2433 | let json = r#"{
2434 | "skip_tools": ["tool.*", "debug_.*", "test_[0-9]+"]
2435 | }"#;
2436 |
2437 | let runtime_config: RuntimeConfig = serde_json::from_str(json).unwrap();
2438 | let skip_tools = runtime_config.skip_tools.as_ref().unwrap();
2439 |
2440 | // Test wildcard patterns
2441 | assert!(skip_tools.is_match("tool1"));
2442 | assert!(skip_tools.is_match("tool_anything"));
2443 | assert!(skip_tools.is_match("toolbox"));
2444 |
2445 | // Test prefix patterns
2446 | assert!(skip_tools.is_match("debug_info"));
2447 | assert!(skip_tools.is_match("debug_error"));
2448 |
2449 | // Test numbered patterns
2450 | assert!(skip_tools.is_match("test_1"));
2451 | assert!(skip_tools.is_match("test_99"));
2452 |
2453 | // Test non-matches
2454 | assert!(!skip_tools.is_match("my_tool"));
2455 | assert!(!skip_tools.is_match("debug"));
2456 | assert!(!skip_tools.is_match("test_abc"));
2457 | // "tool" should match "tool.*" pattern since it becomes "^tool.*$"
2458 | assert!(skip_tools.is_match("tool"));
2459 | }
2460 |
2461 | #[test]
2462 | fn test_skip_tools_anchoring_behavior() {
2463 | let json = r#"{
2464 | "skip_tools": ["tool", "^prefix_.*", ".*_suffix$", "^exact_match$"]
2465 | }"#;
2466 |
2467 | let runtime_config: RuntimeConfig = serde_json::from_str(json).unwrap();
2468 | let skip_tools = runtime_config.skip_tools.as_ref().unwrap();
2469 |
2470 | // "tool" should be auto-anchored to "^tool$"
2471 | assert!(skip_tools.is_match("tool"));
2472 | assert!(!skip_tools.is_match("tool_extended"));
2473 | assert!(!skip_tools.is_match("my_tool"));
2474 |
2475 | // "^prefix_.*" should match anything starting with "prefix_"
2476 | assert!(skip_tools.is_match("prefix_anything"));
2477 | assert!(skip_tools.is_match("prefix_"));
2478 | assert!(!skip_tools.is_match("my_prefix_tool"));
2479 |
2480 | // ".*_suffix$" should match anything ending with "_suffix"
2481 | assert!(skip_tools.is_match("any_suffix"));
2482 | assert!(skip_tools.is_match("_suffix"));
2483 | assert!(!skip_tools.is_match("suffix_extended"));
2484 |
2485 | // "^exact_match$" should only match exactly "exact_match"
2486 | assert!(skip_tools.is_match("exact_match"));
2487 | assert!(!skip_tools.is_match("exact_match_extended"));
2488 | // "prefix_exact_match" matches "^prefix_.*" pattern, not "^exact_match$"
2489 | assert!(skip_tools.is_match("prefix_exact_match"));
2490 | }
2491 |
2492 | #[test]
2493 | fn test_skip_tools_serialization_roundtrip() {
2494 | let original_patterns = vec![
2495 | "tool1".to_string(),
2496 | "tool.*".to_string(),
2497 | "debug_.*".to_string(),
2498 | ];
2499 | let regex_set = RegexSet::new(&original_patterns).unwrap();
2500 |
2501 | let runtime_config = RuntimeConfig {
2502 | skip_prompts: None,
2503 | skip_resource_templates: None,
2504 | skip_resources: None,
2505 | skip_tools: Some(regex_set),
2506 | allowed_hosts: None,
2507 | allowed_paths: None,
2508 | env_vars: None,
2509 | memory_limit: None,
2510 | };
2511 |
2512 | // Serialize
2513 | let json = serde_json::to_string(&runtime_config).unwrap();
2514 |
2515 | // Deserialize
2516 | let deserialized: RuntimeConfig = serde_json::from_str(&json).unwrap();
2517 | let skip_tools = deserialized.skip_tools.as_ref().unwrap();
2518 |
2519 | // Verify functionality is preserved
2520 | assert!(skip_tools.is_match("tool1"));
2521 | assert!(skip_tools.is_match("tool_anything"));
2522 | assert!(skip_tools.is_match("debug_info"));
2523 | assert!(!skip_tools.is_match("other_tool"));
2524 | }
2525 |
2526 | #[test]
2527 | fn test_skip_tools_yaml_deserialization() {
2528 | let yaml = r#"
2529 | skip_tools:
2530 | - "tool1"
2531 | - "tool.*"
2532 | - "debug_.*"
2533 | allowed_hosts:
2534 | - "example.com"
2535 | "#;
2536 |
2537 | let runtime_config: RuntimeConfig = serde_yaml::from_str(yaml).unwrap();
2538 | let skip_tools = runtime_config.skip_tools.as_ref().unwrap();
2539 |
2540 | assert!(skip_tools.is_match("tool1"));
2541 | assert!(skip_tools.is_match("tool_test"));
2542 | assert!(skip_tools.is_match("debug_info"));
2543 | assert!(!skip_tools.is_match("other"));
2544 | }
2545 |
2546 | #[test]
2547 | fn test_skip_tools_invalid_regex() {
2548 | let json = r#"{
2549 | "skip_tools": ["valid_tool", "[unclosed_bracket", "another_valid"]
2550 | }"#;
2551 |
2552 | let result: Result<RuntimeConfig, _> = serde_json::from_str(json);
2553 | assert!(result.is_err());
2554 |
2555 | let error_msg = result.unwrap_err().to_string();
2556 | assert!(error_msg.contains("regex") || error_msg.contains("bracket"));
2557 | }
2558 |
2559 | #[test]
2560 | fn test_skip_tools_empty_patterns() {
2561 | let json = r#"{
2562 | "skip_tools": []
2563 | }"#;
2564 |
2565 | let runtime_config: RuntimeConfig = serde_json::from_str(json).unwrap();
2566 | let skip_tools = runtime_config.skip_tools.as_ref().unwrap();
2567 |
2568 | assert_eq!(skip_tools.len(), 0);
2569 | assert!(!skip_tools.is_match("anything"));
2570 | }
2571 |
2572 | #[test]
2573 | fn test_skip_tools_special_regex_characters() {
2574 | let json = r#"{
2575 | "skip_tools": ["tool\\.exe", "script\\?", "temp\\*file"]
2576 | }"#;
2577 |
2578 | let runtime_config: RuntimeConfig = serde_json::from_str(json).unwrap();
2579 | let skip_tools = runtime_config.skip_tools.as_ref().unwrap();
2580 |
2581 | // Test literal matching of special characters
2582 | assert!(skip_tools.is_match("tool.exe"));
2583 | assert!(skip_tools.is_match("script?"));
2584 | assert!(skip_tools.is_match("temp*file"));
2585 |
2586 | // These should not match due to anchoring
2587 | assert!(!skip_tools.is_match("my_tool.exe"));
2588 | assert!(!skip_tools.is_match("script?.bat"));
2589 | }
2590 |
2591 | #[test]
2592 | fn test_skip_tools_case_sensitivity() {
2593 | let json = r#"{
2594 | "skip_tools": ["Tool", "DEBUG.*"]
2595 | }"#;
2596 |
2597 | let runtime_config: RuntimeConfig = serde_json::from_str(json).unwrap();
2598 | let skip_tools = runtime_config.skip_tools.as_ref().unwrap();
2599 |
2600 | // RegexSet is case sensitive by default
2601 | assert!(skip_tools.is_match("Tool"));
2602 | assert!(!skip_tools.is_match("tool"));
2603 | assert!(!skip_tools.is_match("TOOL"));
2604 |
2605 | assert!(skip_tools.is_match("DEBUG_info"));
2606 | assert!(!skip_tools.is_match("debug_info"));
2607 | }
2608 |
2609 | #[test]
2610 | fn test_skip_tools_default_behavior() {
2611 | // Test that skip_tools defaults to None when not specified
2612 | let json = r#"{
2613 | "allowed_hosts": ["example.com"]
2614 | }"#;
2615 |
2616 | let runtime_config: RuntimeConfig = serde_json::from_str(json).unwrap();
2617 | assert!(runtime_config.skip_tools.is_none());
2618 | }
2619 |
2620 | #[test]
2621 | fn test_skip_tools_matching_functionality() {
2622 | let patterns = vec![
2623 | "exact".to_string(),
2624 | "prefix.*".to_string(),
2625 | ".*suffix".to_string(),
2626 | ];
2627 | let regex_set = RegexSet::new(
2628 | patterns
2629 | .iter()
2630 | .map(|p| format!("^{}$", p))
2631 | .collect::<Vec<_>>(),
2632 | )
2633 | .unwrap();
2634 |
2635 | // Test exact match
2636 | assert!(regex_set.is_match("exact"));
2637 | assert!(!regex_set.is_match("exact_more"));
2638 |
2639 | // Test prefix match
2640 | assert!(regex_set.is_match("prefix123"));
2641 | assert!(regex_set.is_match("prefixABC"));
2642 | assert!(!regex_set.is_match("not_prefix123"));
2643 |
2644 | // Test suffix match
2645 | assert!(regex_set.is_match("anysuffix"));
2646 | assert!(regex_set.is_match("123suffix"));
2647 | assert!(!regex_set.is_match("suffix_more"));
2648 | }
2649 |
2650 | #[test]
2651 | fn test_skip_tools_examples_integration() {
2652 | let rt = Runtime::new().unwrap();
2653 |
2654 | // Load the skip_tools examples config
2655 | let path = Path::new("tests/fixtures/skip_tools_examples.yaml");
2656 | let cli = Cli {
2657 | config_file: Some(path.to_path_buf()),
2658 |
2659 | ..Default::default()
2660 | };
2661 |
2662 | let config_result = rt.block_on(load_config(&cli));
2663 | assert!(
2664 | config_result.is_ok(),
2665 | "Failed to load skip_tools examples config"
2666 | );
2667 |
2668 | let config = config_result.unwrap();
2669 | assert_eq!(
2670 | config.plugins.len(),
2671 | 10,
2672 | "Expected 10 plugins in the config"
2673 | );
2674 |
2675 | // Test exact_match_plugin
2676 | let exact_plugin = &config.plugins[&PluginName("exact_match_plugin".to_string())];
2677 | let exact_skip_tools = exact_plugin
2678 | .runtime_config
2679 | .as_ref()
2680 | .unwrap()
2681 | .skip_tools
2682 | .as_ref()
2683 | .unwrap();
2684 | assert!(exact_skip_tools.is_match("debug_tool"));
2685 | assert!(exact_skip_tools.is_match("test_runner"));
2686 | assert!(exact_skip_tools.is_match("deprecated_helper"));
2687 | assert!(!exact_skip_tools.is_match("other_tool"));
2688 | assert!(!exact_skip_tools.is_match("debug_tool_extended"));
2689 |
2690 | // Test wildcard_plugin
2691 | let wildcard_plugin = &config.plugins[&PluginName("wildcard_plugin".to_string())];
2692 | let wildcard_skip_tools = wildcard_plugin
2693 | .runtime_config
2694 | .as_ref()
2695 | .unwrap()
2696 | .skip_tools
2697 | .as_ref()
2698 | .unwrap();
2699 | assert!(wildcard_skip_tools.is_match("temp_file"));
2700 | assert!(wildcard_skip_tools.is_match("temp_data"));
2701 | assert!(wildcard_skip_tools.is_match("file_backup"));
2702 | assert!(wildcard_skip_tools.is_match("data_backup"));
2703 | assert!(wildcard_skip_tools.is_match("debug"));
2704 | assert!(wildcard_skip_tools.is_match("debugger"));
2705 | assert!(!wildcard_skip_tools.is_match("backup_file"));
2706 | assert!(!wildcard_skip_tools.is_match("temp"));
2707 |
2708 | // Test regex_plugin
2709 | let regex_plugin = &config.plugins[&PluginName("regex_plugin".to_string())];
2710 | let regex_skip_tools = regex_plugin
2711 | .runtime_config
2712 | .as_ref()
2713 | .unwrap()
2714 | .skip_tools
2715 | .as_ref()
2716 | .unwrap();
2717 | assert!(regex_skip_tools.is_match("tool_1"));
2718 | assert!(regex_skip_tools.is_match("tool_42"));
2719 | assert!(regex_skip_tools.is_match("test_unit"));
2720 | assert!(regex_skip_tools.is_match("test_integration"));
2721 | assert!(regex_skip_tools.is_match("data_helper"));
2722 | assert!(!regex_skip_tools.is_match("tool_abc"));
2723 | assert!(!regex_skip_tools.is_match("test_system"));
2724 | assert!(!regex_skip_tools.is_match("Data_helper"));
2725 |
2726 | // Test anchored_plugin
2727 | let anchored_plugin = &config.plugins[&PluginName("anchored_plugin".to_string())];
2728 | let anchored_skip_tools = anchored_plugin
2729 | .runtime_config
2730 | .as_ref()
2731 | .unwrap()
2732 | .skip_tools
2733 | .as_ref()
2734 | .unwrap();
2735 | assert!(anchored_skip_tools.is_match("system_tool"));
2736 | assert!(anchored_skip_tools.is_match("data_internal"));
2737 | assert!(anchored_skip_tools.is_match("exact_only"));
2738 | assert!(!anchored_skip_tools.is_match("my_system_tool"));
2739 | assert!(!anchored_skip_tools.is_match("data_internal_ext"));
2740 | assert!(!anchored_skip_tools.is_match("exact_only_more"));
2741 |
2742 | // Test case_sensitive_plugin
2743 | let case_plugin = &config.plugins[&PluginName("case_sensitive_plugin".to_string())];
2744 | let case_skip_tools = case_plugin
2745 | .runtime_config
2746 | .as_ref()
2747 | .unwrap()
2748 | .skip_tools
2749 | .as_ref()
2750 | .unwrap();
2751 | assert!(case_skip_tools.is_match("Tool"));
2752 | assert!(!case_skip_tools.is_match("tool"));
2753 | assert!(!case_skip_tools.is_match("TOOL"));
2754 | assert!(case_skip_tools.is_match("DEBUG_info"));
2755 | assert!(!case_skip_tools.is_match("debug_info"));
2756 | assert!(case_skip_tools.is_match("CamelCaseHelper"));
2757 | assert!(!case_skip_tools.is_match("camelCaseHelper"));
2758 |
2759 | // Test special_chars_plugin
2760 | let special_plugin = &config.plugins[&PluginName("special_chars_plugin".to_string())];
2761 | let special_skip_tools = special_plugin
2762 | .runtime_config
2763 | .as_ref()
2764 | .unwrap()
2765 | .skip_tools
2766 | .as_ref()
2767 | .unwrap();
2768 | assert!(special_skip_tools.is_match("file.exe"));
2769 | assert!(special_skip_tools.is_match("script?"));
2770 | assert!(special_skip_tools.is_match("temp*data"));
2771 | assert!(special_skip_tools.is_match("path\\tool"));
2772 | assert!(!special_skip_tools.is_match("fileXexe"));
2773 | assert!(!special_skip_tools.is_match("script"));
2774 |
2775 | // Test empty_skip_plugin
2776 | let empty_plugin = &config.plugins[&PluginName("empty_skip_plugin".to_string())];
2777 | let empty_skip_tools = empty_plugin
2778 | .runtime_config
2779 | .as_ref()
2780 | .unwrap()
2781 | .skip_tools
2782 | .as_ref()
2783 | .unwrap();
2784 | assert_eq!(empty_skip_tools.len(), 0);
2785 | assert!(!empty_skip_tools.is_match("anything"));
2786 |
2787 | // Test no_skip_plugin
2788 | let no_skip_plugin = &config.plugins[&PluginName("no_skip_plugin".to_string())];
2789 | assert!(
2790 | no_skip_plugin
2791 | .runtime_config
2792 | .as_ref()
2793 | .unwrap()
2794 | .skip_tools
2795 | .is_none()
2796 | );
2797 |
2798 | // Test full_config_plugin has all components
2799 | let full_plugin = &config.plugins[&PluginName("full_config_plugin".to_string())];
2800 | let full_runtime = full_plugin.runtime_config.as_ref().unwrap();
2801 | let full_skip_tools = full_runtime.skip_tools.as_ref().unwrap();
2802 | assert!(full_skip_tools.is_match("admin_tool"));
2803 | assert!(full_skip_tools.is_match("tool_dangerous"));
2804 | assert!(full_skip_tools.is_match("system_critical"));
2805 | assert!(!full_skip_tools.is_match("safe_tool"));
2806 | assert_eq!(full_runtime.allowed_hosts.as_ref().unwrap().len(), 2);
2807 | assert_eq!(full_runtime.allowed_paths.as_ref().unwrap().len(), 2);
2808 | assert_eq!(full_runtime.env_vars.as_ref().unwrap().len(), 2);
2809 | assert_eq!(full_runtime.memory_limit.as_ref().unwrap(), "2GB");
2810 | }
2811 | }
2812 |
```