rust_docs_mcp/cache/
types.rs

1//! Type definitions for improved type safety in the cache module
2//!
3//! This module provides strongly-typed wrappers for common data patterns
4//! to prevent stringly-typed errors and improve API clarity.
5
6use anyhow::{Result, bail};
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use std::path::{Path, PathBuf};
10use std::str::FromStr;
11
12/// Validate that a crate name is safe for use in file paths
13fn validate_crate_name(name: &str) -> Result<()> {
14    // Check for path traversal attempts
15    if name.contains("..") || name.contains("/") || name.contains("\\") {
16        bail!(
17            "Invalid crate name '{}': contains path separators or traversal sequences",
18            name
19        );
20    }
21
22    // Check for absolute paths
23    if name.starts_with('/')
24        || name.starts_with('\\')
25        || (name.len() > 2 && name.chars().nth(1) == Some(':'))
26    {
27        bail!(
28            "Invalid crate name '{}': appears to be an absolute path",
29            name
30        );
31    }
32
33    // Ensure it's a valid crate name pattern (alphanumeric, underscore, dash)
34    if !name
35        .chars()
36        .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
37    {
38        bail!(
39            "Invalid crate name '{}': contains invalid characters. Only alphanumeric, underscore, and dash are allowed",
40            name
41        );
42    }
43
44    Ok(())
45}
46
47/// Represents a crate identifier with name and version
48#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
49pub struct CrateIdentifier {
50    name: String,
51    version: String,
52}
53
54impl CrateIdentifier {
55    /// Create a new crate identifier
56    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Result<Self> {
57        let name = name.into();
58        let version = version.into();
59
60        // Validate crate name
61        if name.is_empty() {
62            bail!("Crate name cannot be empty");
63        }
64
65        // Validate for path traversal and other security issues
66        validate_crate_name(&name)?;
67
68        // Validate version
69        if version.is_empty() {
70            bail!("Crate version cannot be empty");
71        }
72
73        Ok(Self { name, version })
74    }
75
76    /// Get the crate name
77    pub fn name(&self) -> &str {
78        &self.name
79    }
80
81    /// Get the crate version
82    pub fn version(&self) -> &str {
83        &self.version
84    }
85}
86
87impl fmt::Display for CrateIdentifier {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        write!(f, "{}-{}", self.name, self.version)
90    }
91}
92
93impl FromStr for CrateIdentifier {
94    type Err = anyhow::Error;
95
96    fn from_str(s: &str) -> Result<Self> {
97        let parts: Vec<&str> = s.rsplitn(2, '-').collect();
98        if parts.len() != 2 {
99            bail!("Invalid crate identifier format. Expected 'name-version'");
100        }
101
102        // Note: rsplitn returns in reverse order
103        let version = parts[0];
104        let name = parts[1];
105
106        Self::new(name, version)
107    }
108}
109
110/// Represents a path to a workspace member
111#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
112pub struct MemberPath {
113    path: PathBuf,
114    member_name: String,
115}
116
117impl MemberPath {
118    /// Create a new member path
119    pub fn new(path: impl AsRef<Path>) -> Result<Self> {
120        let path = path.as_ref();
121
122        // Validate path
123        if path.as_os_str().is_empty() {
124            bail!("Member path cannot be empty");
125        }
126
127        // Extract member name from the path
128        let member_name = path
129            .file_name()
130            .and_then(|n| n.to_str())
131            .ok_or_else(|| anyhow::anyhow!("Invalid member path: no file name component"))?
132            .to_string();
133
134        Ok(Self {
135            path: path.to_path_buf(),
136            member_name,
137        })
138    }
139}
140
141impl fmt::Display for MemberPath {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        write!(f, "{}", self.path.display())
144    }
145}
146
147impl FromStr for MemberPath {
148    type Err = anyhow::Error;
149
150    fn from_str(s: &str) -> Result<Self> {
151        Self::new(s)
152    }
153}
154
155impl AsRef<Path> for MemberPath {
156    fn as_ref(&self) -> &Path {
157        &self.path
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn test_crate_identifier() -> Result<()> {
167        let id = CrateIdentifier::new("serde", "1.0.0")?;
168        assert_eq!(id.name(), "serde");
169        assert_eq!(id.version(), "1.0.0");
170        assert_eq!(id.to_string(), "serde-1.0.0");
171
172        // Test validation
173        assert!(CrateIdentifier::new("", "1.0.0").is_err());
174        assert!(CrateIdentifier::new("serde", "").is_err());
175
176        Ok(())
177    }
178
179    #[test]
180    fn test_crate_identifier_security_validation() -> Result<()> {
181        // Test path traversal attempts
182        assert!(CrateIdentifier::new("../../../etc/passwd", "1.0.0").is_err());
183        assert!(CrateIdentifier::new("crate/../../../etc", "1.0.0").is_err());
184        assert!(CrateIdentifier::new("..", "1.0.0").is_err());
185        assert!(CrateIdentifier::new(".", "1.0.0").is_err());
186
187        // Test path separators
188        assert!(CrateIdentifier::new("crate/subcrate", "1.0.0").is_err());
189        assert!(CrateIdentifier::new("crate\\subcrate", "1.0.0").is_err());
190        assert!(CrateIdentifier::new("/absolute/path", "1.0.0").is_err());
191        assert!(CrateIdentifier::new("\\absolute\\path", "1.0.0").is_err());
192        assert!(CrateIdentifier::new("C:\\windows", "1.0.0").is_err());
193        assert!(CrateIdentifier::new("C:/windows", "1.0.0").is_err());
194
195        // Test invalid characters
196        assert!(CrateIdentifier::new("crate$name", "1.0.0").is_err());
197        assert!(CrateIdentifier::new("crate@name", "1.0.0").is_err());
198        assert!(CrateIdentifier::new("crate name", "1.0.0").is_err());
199        assert!(CrateIdentifier::new("crate\nname", "1.0.0").is_err());
200        assert!(CrateIdentifier::new("crate\0name", "1.0.0").is_err());
201
202        // Test valid names
203        assert!(CrateIdentifier::new("valid_crate", "1.0.0").is_ok());
204        assert!(CrateIdentifier::new("valid-crate", "1.0.0").is_ok());
205        assert!(CrateIdentifier::new("Valid123", "1.0.0").is_ok());
206        assert!(CrateIdentifier::new("a", "1.0.0").is_ok());
207
208        Ok(())
209    }
210
211    #[test]
212    fn test_crate_identifier_from_str() -> Result<()> {
213        let id: CrateIdentifier = "serde-1.0.0".parse()?;
214        assert_eq!(id.name(), "serde");
215        assert_eq!(id.version(), "1.0.0");
216
217        // Test with crate names containing hyphens
218        let id: CrateIdentifier = "rust-docs-mcp-0.1.0".parse()?;
219        assert_eq!(id.name(), "rust-docs-mcp");
220        assert_eq!(id.version(), "0.1.0");
221
222        // Test invalid format
223        assert!("invalid".parse::<CrateIdentifier>().is_err());
224
225        Ok(())
226    }
227
228    #[test]
229    fn test_member_path() -> Result<()> {
230        let member = MemberPath::new("crates/rmcp")?;
231        assert_eq!(member.path, Path::new("crates/rmcp"));
232        assert_eq!(member.member_name, "rmcp");
233
234        // Test validation
235        assert!(MemberPath::new("").is_err());
236
237        Ok(())
238    }
239
240    #[test]
241    fn test_validate_crate_name() {
242        // Valid names
243        assert!(validate_crate_name("serde").is_ok());
244        assert!(validate_crate_name("tokio-util").is_ok());
245        assert!(validate_crate_name("async_trait").is_ok());
246        assert!(validate_crate_name("log2").is_ok());
247        assert!(validate_crate_name("h3").is_ok());
248
249        // Path traversal attempts
250        assert!(validate_crate_name("../etc/passwd").is_err());
251        assert!(validate_crate_name("crate/../../../etc").is_err());
252        assert!(validate_crate_name("..").is_err());
253        assert!(validate_crate_name("./config").is_err());
254        assert!(validate_crate_name("crate/..").is_err());
255
256        // Path separators
257        assert!(validate_crate_name("some/path").is_err());
258        assert!(validate_crate_name("some\\path").is_err());
259        assert!(validate_crate_name("path/to/crate").is_err());
260
261        // Absolute paths
262        assert!(validate_crate_name("/etc/passwd").is_err());
263        assert!(validate_crate_name("\\Windows\\System32").is_err());
264        assert!(validate_crate_name("C:\\Windows").is_err());
265        assert!(validate_crate_name("C:").is_err());
266
267        // Invalid characters
268        assert!(validate_crate_name("crate@2.0").is_err());
269        assert!(validate_crate_name("my crate").is_err());
270        assert!(validate_crate_name("crate!name").is_err());
271        assert!(validate_crate_name("crate#name").is_err());
272        assert!(validate_crate_name("crate$name").is_err());
273    }
274
275    #[test]
276    fn test_crate_identifier_validation() {
277        // Valid crate identifiers
278        assert!(CrateIdentifier::new("serde", "1.0.0").is_ok());
279        assert!(CrateIdentifier::new("tokio-util", "0.7.0").is_ok());
280
281        // Invalid names should fail
282        assert!(CrateIdentifier::new("../malicious", "1.0.0").is_err());
283        assert!(CrateIdentifier::new("/etc/passwd", "1.0.0").is_err());
284        assert!(CrateIdentifier::new("crate@2.0", "1.0.0").is_err());
285
286        // Empty names/versions should fail
287        assert!(CrateIdentifier::new("", "1.0.0").is_err());
288        assert!(CrateIdentifier::new("serde", "").is_err());
289    }
290}