Skip to main content

crates_docs/tools/docs/cache/
key.rs

1//! Cache key generation and validation for document cache
2
3use std::collections::hash_map::DefaultHasher;
4use std::hash::{Hash, Hasher};
5
6fn normalize_version(version: Option<&str>) -> Option<String> {
7    version.map(|v| v.trim().to_lowercase())
8}
9
10fn is_valid_crate_name_char(b: u8) -> bool {
11    b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
12}
13
14fn is_valid_item_path_char(b: u8) -> bool {
15    b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b':'
16}
17
18fn is_valid_crate_name(name: &str) -> bool {
19    !name.is_empty() && name.bytes().all(is_valid_crate_name_char)
20}
21
22fn is_valid_item_path(path: &str) -> bool {
23    !path.is_empty() && path.bytes().all(is_valid_item_path_char)
24}
25
26/// Cache key generator for document cache
27pub struct CacheKeyGenerator;
28
29impl CacheKeyGenerator {
30    /// Build crate cache key with normalization
31    ///
32    /// # Normalization rules
33    ///
34    /// - `crate_name`: lowercase
35    /// - `version`: lowercase, trimmed
36    /// - Invalid characters in `crate_name` (non-alphanumeric, non-underscore, non-hyphen)
37    ///   will result in a hashed key to prevent injection
38    #[must_use]
39    pub fn crate_cache_key(crate_name: &str, version: Option<&str>) -> String {
40        let normalized_name = crate_name.to_lowercase();
41
42        if !is_valid_crate_name(&normalized_name) {
43            let mut hasher = DefaultHasher::new();
44            normalized_name.hash(&mut hasher);
45            let hash = hasher.finish();
46            return match normalize_version(version) {
47                Some(normalized_ver) => format!("crate:hash:{hash}:{normalized_ver}"),
48                None => format!("crate:hash:{hash}"),
49            };
50        }
51
52        match normalize_version(version) {
53            Some(normalized_ver) => format!("crate:{normalized_name}:{normalized_ver}"),
54            None => format!("crate:{normalized_name}"),
55        }
56    }
57
58    /// Build search cache key with normalization
59    ///
60    /// # Normalization rules
61    ///
62    /// - query: lowercase, trimmed (search is case-insensitive)
63    #[must_use]
64    pub fn search_cache_key(query: &str, limit: u32) -> String {
65        let normalized_query = query.trim().to_lowercase();
66        format!("search:{normalized_query}:{limit}")
67    }
68
69    /// Build item cache key with normalization
70    ///
71    /// # Normalization rules
72    ///
73    /// - `crate_name`: lowercase
74    /// - `item_path`: trimmed but case-sensitive (Rust paths are case-sensitive)
75    /// - `version`: lowercase, trimmed
76    #[must_use]
77    pub fn item_cache_key(crate_name: &str, item_path: &str, version: Option<&str>) -> String {
78        let normalized_name = crate_name.to_lowercase();
79        let normalized_path = item_path.trim();
80
81        if !is_valid_crate_name(&normalized_name) || !is_valid_item_path(normalized_path) {
82            let mut hasher = DefaultHasher::new();
83            normalized_name.hash(&mut hasher);
84            normalized_path.hash(&mut hasher);
85            let hash = hasher.finish();
86            return match normalize_version(version) {
87                Some(normalized_ver) => {
88                    format!("item:{normalized_name}:{normalized_ver}:hash:{hash}")
89                }
90                None => format!("item:{normalized_name}:hash:{hash}"),
91            };
92        }
93
94        match normalize_version(version) {
95            Some(normalized_ver) => {
96                format!("item:{normalized_name}:{normalized_ver}:{normalized_path}")
97            }
98            None => format!("item:{normalized_name}:{normalized_path}"),
99        }
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn test_cache_key_generation() {
109        assert_eq!(
110            CacheKeyGenerator::crate_cache_key("serde", None),
111            "crate:serde"
112        );
113        assert_eq!(
114            CacheKeyGenerator::crate_cache_key("serde", Some("1.0")),
115            "crate:serde:1.0"
116        );
117
118        assert_eq!(
119            CacheKeyGenerator::search_cache_key("web framework", 10),
120            "search:web framework:10"
121        );
122
123        assert_eq!(
124            CacheKeyGenerator::item_cache_key("serde", "Serialize", None),
125            "item:serde:Serialize"
126        );
127        assert_eq!(
128            CacheKeyGenerator::item_cache_key("serde", "Serialize", Some("1.0")),
129            "item:serde:1.0:Serialize"
130        );
131    }
132
133    #[test]
134    fn test_cache_key_normalization_case_insensitivity() {
135        assert_eq!(
136            CacheKeyGenerator::crate_cache_key("Serde", None),
137            CacheKeyGenerator::crate_cache_key("serde", None)
138        );
139        assert_eq!(
140            CacheKeyGenerator::crate_cache_key("SERDE", None),
141            CacheKeyGenerator::crate_cache_key("serde", None)
142        );
143
144        assert_eq!(
145            CacheKeyGenerator::crate_cache_key("Tokio", Some("1.0")),
146            CacheKeyGenerator::crate_cache_key("tokio", Some("1.0"))
147        );
148
149        assert_eq!(
150            CacheKeyGenerator::search_cache_key("Web Framework", 10),
151            CacheKeyGenerator::search_cache_key("web framework", 10)
152        );
153
154        assert_eq!(
155            CacheKeyGenerator::item_cache_key("Serde", "Serialize", None),
156            CacheKeyGenerator::item_cache_key("serde", "Serialize", None)
157        );
158    }
159
160    #[test]
161    fn test_cache_key_normalization_whitespace() {
162        assert_eq!(
163            CacheKeyGenerator::crate_cache_key("serde", Some(" 1.0 ")),
164            "crate:serde:1.0"
165        );
166
167        assert_eq!(
168            CacheKeyGenerator::search_cache_key("  web framework  ", 10),
169            "search:web framework:10"
170        );
171
172        assert_eq!(
173            CacheKeyGenerator::item_cache_key("serde", "  Serialize  ", None),
174            "item:serde:Serialize"
175        );
176    }
177
178    #[test]
179    fn test_cache_key_normalization_version_case() {
180        assert_eq!(
181            CacheKeyGenerator::crate_cache_key("serde", Some("1.0-RC1")),
182            "crate:serde:1.0-rc1"
183        );
184        assert_eq!(
185            CacheKeyGenerator::item_cache_key("serde", "Serialize", Some("V1.0")),
186            "item:serde:v1.0:Serialize"
187        );
188    }
189
190    #[test]
191    fn test_cache_key_injection_prevention() {
192        let malicious_key = CacheKeyGenerator::crate_cache_key("serde:malicious", None);
193        assert!(malicious_key.starts_with("crate:hash:"));
194        assert!(!malicious_key.contains("serde:malicious"));
195
196        let malicious_key_with_version =
197            CacheKeyGenerator::crate_cache_key("crate:evil", Some("1.0"));
198        assert!(malicious_key_with_version.starts_with("crate:hash:"));
199        assert!(!malicious_key_with_version.contains("crate:evil"));
200
201        let valid_key = CacheKeyGenerator::crate_cache_key("serde-json", None);
202        assert_eq!(valid_key, "crate:serde-json");
203
204        let valid_key_underscore = CacheKeyGenerator::crate_cache_key("my_crate", None);
205        assert_eq!(valid_key_underscore, "crate:my_crate");
206    }
207
208    #[test]
209    fn test_item_path_case_sensitivity() {
210        assert_ne!(
211            CacheKeyGenerator::item_cache_key("serde", "Serialize", None),
212            CacheKeyGenerator::item_cache_key("serde", "serialize", None)
213        );
214    }
215
216    #[test]
217    fn test_cache_key_edge_cases() {
218        let empty_key = CacheKeyGenerator::crate_cache_key("", None);
219        assert!(empty_key.starts_with("crate:hash:"));
220
221        let whitespace_key = CacheKeyGenerator::crate_cache_key("   ", None);
222        assert!(whitespace_key.starts_with("crate:hash:"));
223
224        assert_eq!(
225            CacheKeyGenerator::crate_cache_key("serde", Some("")),
226            "crate:serde:"
227        );
228
229        let unicode_key = CacheKeyGenerator::crate_cache_key("serde测试", None);
230        assert!(unicode_key.starts_with("crate:hash:"));
231        assert!(!unicode_key.contains("测试"));
232
233        let malicious_item_path =
234            CacheKeyGenerator::item_cache_key("serde", "Serialize\nmalicious", None);
235        assert!(malicious_item_path.contains("hash:"));
236        assert!(!malicious_item_path.contains('\n'));
237
238        let malicious_item_colon =
239            CacheKeyGenerator::item_cache_key("serde", "Serialize:extra:colons", None);
240        assert_eq!(malicious_item_colon, "item:serde:Serialize:extra:colons");
241
242        let valid_item_path = CacheKeyGenerator::item_cache_key("serde", "serde::Serialize", None);
243        assert_eq!(valid_item_path, "item:serde:serde::Serialize");
244
245        let empty_item_key = CacheKeyGenerator::item_cache_key("serde", "", None);
246        assert!(empty_item_key.contains("hash:"));
247
248        let empty_item_crate = CacheKeyGenerator::item_cache_key("", "Crate", None);
249        assert!(empty_item_crate.contains("hash:"));
250    }
251}