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
6/// Check if a byte is a valid crate name character
7#[inline]
8fn is_valid_crate_name_char(b: u8) -> bool {
9    b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
10}
11
12/// Check if a byte is a valid item path character
13#[inline]
14fn is_valid_item_path_char(b: u8) -> bool {
15    b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b':'
16}
17
18/// Check if crate name is valid (non-empty and all valid chars)
19#[inline]
20fn is_valid_crate_name(name: &str) -> bool {
21    !name.is_empty() && name.bytes().all(is_valid_crate_name_char)
22}
23
24/// Check if item path is valid (non-empty, valid chars, colons only as `::`)
25///
26/// Rust paths separate segments with `::` (a double colon). A lone `:` is not a
27/// valid path separator, and allowing it would make the item cache key
28/// ambiguous: `item:a:1.0:Serialize` could mean either path `"1.0:Serialize"`
29/// (no version) or version `"1.0"` + path `"Serialize"`. Rejecting single
30/// colons routes such inputs through the hashed-key branch, keeping keys
31/// injective and matching the tool-level `validate_item_path` guard.
32#[inline]
33fn is_valid_item_path(path: &str) -> bool {
34    !path.is_empty()
35        && path.bytes().all(is_valid_item_path_char)
36        && !path.replace("::", "").contains(':')
37}
38
39/// Reversibly escape `:` and `%` in a free-form cache-key segment.
40///
41/// Cache keys are colon-delimited. Free-form segments such as the search query
42/// or sort string may themselves contain `:`, which would otherwise let two
43/// distinct (query, sort, limit) inputs map to the same key (for example
44/// `query="a", sort="b:c"` and `query="a:b", sort="c"` both yield
45/// `search:a:b:c:{limit}`). Percent-encoding `%` first and then `:` keeps the
46/// mapping injective while leaving colon-free inputs unchanged.
47#[inline]
48fn escape_key_segment(segment: &str) -> String {
49    if segment.contains('%') || segment.contains(':') {
50        segment.replace('%', "%25").replace(':', "%3a")
51    } else {
52        segment.to_string()
53    }
54}
55
56/// Normalize an optional version string for cache-key generation.
57///
58/// Trims surrounding whitespace and lowercases the version (versions are
59/// treated case-insensitively). An empty/whitespace-only version and the
60/// `"latest"` sentinel both normalize to `None` so they share a cache entry
61/// with an unversioned (`None`) lookup. This mirrors the URL builders: for
62/// std crates `rust_lang_docs_base` maps `None`/`"latest"` to the same
63/// unversioned docs, the non-std crate overview at `docs.rs/<crate>/` and
64/// `docs.rs/<crate>/latest/` resolve to identical content, and the item URL
65/// builder maps `None` to `latest`. Keying them separately would duplicate
66/// entries and trigger redundant network fetches.
67#[inline]
68fn normalize_cache_version(version: Option<&str>) -> Option<String> {
69    version
70        .map(|v| v.trim().to_lowercase())
71        .filter(|v| !v.is_empty() && v != "latest")
72}
73
74/// Cache key generator for document cache
75pub struct CacheKeyGenerator;
76
77impl CacheKeyGenerator {
78    /// Build a raw crate HTML cache key with normalization.
79    ///
80    /// This key stores the fetched docs.rs HTML artifact shared across
81    /// markdown, text, and html responses for the same crate lookup.
82    ///
83    /// Key format: `htmlraw:crate:{name}` or `htmlraw:crate:{name}:{version}`
84    ///
85    /// The `htmlraw:` namespace prefix keeps raw HTML artifacts in a separate
86    /// keyspace from rendered documentation keys (`crate:...`). Without it, a
87    /// rendered lookup for version literal `"html"` (e.g.
88    /// `crate_cache_key("serde", Some("html"))` => `crate:serde:html`) would
89    /// collide with the HTML artifact key for `crate_html_cache_key("serde",
90    /// None)`, cross-contaminating rendered text and raw HTML.
91    #[must_use]
92    pub fn crate_html_cache_key(crate_name: &str, version: Option<&str>) -> String {
93        let base_key = Self::crate_cache_key(crate_name, version);
94        format!("htmlraw:{base_key}")
95    }
96
97    /// Build crate cache key with normalization
98    ///
99    /// # Normalization rules
100    ///
101    /// - `crate_name`: lowercase, trimmed
102    ///   (crate names are case-insensitive on crates.io)
103    /// - `version`: lowercase, trimmed
104    /// - Invalid characters in `crate_name` (non-alphanumeric, non-underscore, non-hyphen)
105    ///   will result in a hashed key to prevent injection
106    #[must_use]
107    pub fn crate_cache_key(crate_name: &str, version: Option<&str>) -> String {
108        // Inline normalization to avoid intermediate allocations
109        let normalized_name = crate_name.trim().to_lowercase();
110        let normalized_ver = normalize_cache_version(version);
111
112        if !is_valid_crate_name(&normalized_name) {
113            let mut hasher = DefaultHasher::new();
114            normalized_name.hash(&mut hasher);
115            let hash = hasher.finish();
116            return match normalized_ver {
117                Some(ver) => format!("crate:hash:{hash}:{ver}"),
118                None => format!("crate:hash:{hash}"),
119            };
120        }
121
122        match normalized_ver {
123            Some(ver) => format!("crate:{normalized_name}:{ver}"),
124            None => format!("crate:{normalized_name}"),
125        }
126    }
127
128    /// Build search cache key with normalization
129    ///
130    /// # Normalization rules
131    ///
132    /// - query: lowercase, trimmed (search is case-insensitive)
133    /// - sort: lowercase, trimmed
134    #[must_use]
135    pub fn search_cache_key(query: &str, limit: u32, sort: Option<&str>) -> String {
136        let normalized_query = escape_key_segment(&query.trim().to_lowercase());
137        let normalized_sort =
138            escape_key_segment(&sort.unwrap_or("relevance").trim().to_lowercase());
139        format!("search:{normalized_query}:{normalized_sort}:{limit}")
140    }
141
142    /// Build item cache key with normalization
143    ///
144    /// # Normalization rules
145    ///
146    /// - `crate_name`: lowercase, trimmed
147    ///   (crate names are case-insensitive on crates.io)
148    /// - `item_path`: trimmed but case-sensitive (Rust paths are case-sensitive)
149    /// - `version`: lowercase, trimmed
150    #[must_use]
151    pub fn item_cache_key(crate_name: &str, item_path: &str, version: Option<&str>) -> String {
152        let normalized_name = crate_name.trim().to_lowercase();
153        let normalized_path = item_path.trim();
154        let normalized_ver = normalize_cache_version(version);
155
156        if !is_valid_crate_name(&normalized_name) || !is_valid_item_path(normalized_path) {
157            let mut hasher = DefaultHasher::new();
158            normalized_name.hash(&mut hasher);
159            normalized_path.hash(&mut hasher);
160            let hash = hasher.finish();
161            return match normalized_ver {
162                Some(ver) => {
163                    format!("item:{normalized_name}:{ver}:hash:{hash}")
164                }
165                None => format!("item:{normalized_name}:hash:{hash}"),
166            };
167        }
168
169        match normalized_ver {
170            Some(ver) => {
171                format!("item:{normalized_name}:{ver}:{normalized_path}")
172            }
173            None => format!("item:{normalized_name}:{normalized_path}"),
174        }
175    }
176
177    /// Build a raw item HTML cache key with normalization.
178    ///
179    /// This key stores the fetched docs.rs search-result HTML artifact shared
180    /// across markdown, text, and html responses for the same item lookup.
181    ///
182    /// Key format: `htmlraw:item:{crate}:{path}` (see [`Self::crate_html_cache_key`]
183    /// for why the `htmlraw:` namespace is used to avoid collisions).
184    #[must_use]
185    pub fn item_html_cache_key(crate_name: &str, item_path: &str, version: Option<&str>) -> String {
186        let base_key = Self::item_cache_key(crate_name, item_path, version);
187        format!("htmlraw:{base_key}")
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_cache_key_generation() {
197        assert_eq!(
198            CacheKeyGenerator::crate_cache_key("serde", None),
199            "crate:serde"
200        );
201        assert_eq!(
202            CacheKeyGenerator::crate_cache_key("serde", Some("1.0")),
203            "crate:serde:1.0"
204        );
205        assert_eq!(
206            CacheKeyGenerator::crate_html_cache_key("serde", Some("1.0")),
207            "htmlraw:crate:serde:1.0"
208        );
209
210        assert_eq!(
211            CacheKeyGenerator::search_cache_key("web framework", 10, None),
212            "search:web framework:relevance:10"
213        );
214        assert_eq!(
215            CacheKeyGenerator::search_cache_key("web framework", 10, Some("downloads")),
216            "search:web framework:downloads:10"
217        );
218
219        assert_eq!(
220            CacheKeyGenerator::item_cache_key("serde", "Serialize", None),
221            "item:serde:Serialize"
222        );
223        assert_eq!(
224            CacheKeyGenerator::item_cache_key("serde", "Serialize", Some("1.0")),
225            "item:serde:1.0:Serialize"
226        );
227        assert_eq!(
228            CacheKeyGenerator::item_html_cache_key("serde", "Serialize", Some("1.0")),
229            "htmlraw:item:serde:1.0:Serialize"
230        );
231    }
232
233    #[test]
234    fn test_cache_key_normalization_case_insensitivity() {
235        assert_eq!(
236            CacheKeyGenerator::crate_cache_key("Serde", None),
237            CacheKeyGenerator::crate_cache_key("serde", None)
238        );
239        assert_eq!(
240            CacheKeyGenerator::crate_cache_key("SERDE", None),
241            CacheKeyGenerator::crate_cache_key("serde", None)
242        );
243
244        assert_eq!(
245            CacheKeyGenerator::crate_cache_key("Tokio", Some("1.0")),
246            CacheKeyGenerator::crate_cache_key("tokio", Some("1.0"))
247        );
248
249        assert_eq!(
250            CacheKeyGenerator::search_cache_key("Web Framework", 10, Some("Relevance")),
251            CacheKeyGenerator::search_cache_key("web framework", 10, Some("relevance"))
252        );
253
254        assert_eq!(
255            CacheKeyGenerator::item_cache_key("Serde", "Serialize", None),
256            CacheKeyGenerator::item_cache_key("serde", "Serialize", None)
257        );
258    }
259
260    #[test]
261    fn test_cache_key_normalization_whitespace() {
262        assert_eq!(
263            CacheKeyGenerator::crate_cache_key("serde", Some(" 1.0 ")),
264            "crate:serde:1.0"
265        );
266
267        assert_eq!(
268            CacheKeyGenerator::search_cache_key("  web framework  ", 10, Some(" downloads ")),
269            "search:web framework:downloads:10"
270        );
271
272        assert_eq!(
273            CacheKeyGenerator::item_cache_key("serde", "  Serialize  ", None),
274            "item:serde:Serialize"
275        );
276    }
277
278    #[test]
279    fn test_html_artifact_keys_do_not_collide_with_rendered_keys() {
280        // A rendered lookup for the (pathological) version literal "html" must
281        // not collide with the raw HTML artifact keyspace.
282        let rendered = CacheKeyGenerator::crate_cache_key("serde", Some("html"));
283        let artifact = CacheKeyGenerator::crate_html_cache_key("serde", None);
284        assert_ne!(rendered, artifact);
285        assert_eq!(rendered, "crate:serde:html");
286        assert_eq!(artifact, "htmlraw:crate:serde");
287
288        let rendered_item = CacheKeyGenerator::item_cache_key("serde", "Serialize", Some("html"));
289        let artifact_item = CacheKeyGenerator::item_html_cache_key("serde", "Serialize", None);
290        assert_ne!(rendered_item, artifact_item);
291    }
292
293    #[test]
294    fn test_cache_key_normalization_version_case() {
295        assert_eq!(
296            CacheKeyGenerator::crate_cache_key("serde", Some("1.0-RC1")),
297            "crate:serde:1.0-rc1"
298        );
299        assert_eq!(
300            CacheKeyGenerator::item_cache_key("serde", "Serialize", Some("V1.0")),
301            "item:serde:v1.0:Serialize"
302        );
303    }
304
305    #[test]
306    fn test_cache_key_injection_prevention() {
307        let malicious_key = CacheKeyGenerator::crate_cache_key("serde:malicious", None);
308        assert!(malicious_key.starts_with("crate:hash:"));
309        assert!(!malicious_key.contains("serde:malicious"));
310
311        let malicious_key_with_version =
312            CacheKeyGenerator::crate_cache_key("crate:evil", Some("1.0"));
313        assert!(malicious_key_with_version.starts_with("crate:hash:"));
314        assert!(!malicious_key_with_version.contains("crate:evil"));
315
316        let valid_key = CacheKeyGenerator::crate_cache_key("serde-json", None);
317        assert_eq!(valid_key, "crate:serde-json");
318
319        let valid_key_underscore = CacheKeyGenerator::crate_cache_key("my_crate", None);
320        assert_eq!(valid_key_underscore, "crate:my_crate");
321    }
322
323    #[test]
324    fn test_search_cache_key_no_colon_collision() {
325        // Two distinct (query, sort) inputs that previously collapsed to the
326        // same colon-delimited key must now produce distinct keys.
327        let a = CacheKeyGenerator::search_cache_key("a", 5, Some("b:c"));
328        let b = CacheKeyGenerator::search_cache_key("a:b", 5, Some("c"));
329        assert_ne!(a, b);
330
331        // Colon-free inputs stay human-readable and unescaped.
332        assert_eq!(
333            CacheKeyGenerator::search_cache_key("web framework", 10, Some("downloads")),
334            "search:web framework:downloads:10"
335        );
336
337        // A literal percent in the query must not be confused with an escape.
338        let pct = CacheKeyGenerator::search_cache_key("100%", 10, None);
339        let escaped = CacheKeyGenerator::search_cache_key("100%3a", 10, None);
340        assert_ne!(pct, escaped);
341    }
342
343    #[test]
344    fn test_item_cache_key_no_version_path_collision() {
345        // path "1.0:Serialize" (no version) must NOT collide with
346        // version "1.0" + path "Serialize".
347        let a = CacheKeyGenerator::item_cache_key("serde", "1.0:Serialize", None);
348        let b = CacheKeyGenerator::item_cache_key("serde", "Serialize", Some("1.0"));
349        assert_ne!(a, b);
350        // Legitimate `::` paths stay readable/unhashed.
351        assert_eq!(
352            CacheKeyGenerator::item_cache_key("serde", "de::Deserialize", None),
353            "item:serde:de::Deserialize"
354        );
355    }
356
357    #[test]
358    fn test_cache_key_version_latest_normalizes_to_none() {
359        // `None`, `Some("latest")`, and whitespace/empty versions all fetch the
360        // same content, so they must share a single cache key.
361        let none = CacheKeyGenerator::crate_cache_key("serde", None);
362        assert_eq!(none, "crate:serde");
363        assert_eq!(
364            CacheKeyGenerator::crate_cache_key("serde", Some("latest")),
365            none
366        );
367        assert_eq!(
368            CacheKeyGenerator::crate_cache_key("serde", Some("LATEST")),
369            none
370        );
371        assert_eq!(
372            CacheKeyGenerator::crate_cache_key("serde", Some(" latest ")),
373            none
374        );
375        assert_eq!(CacheKeyGenerator::crate_cache_key("serde", Some("")), none);
376        assert_eq!(
377            CacheKeyGenerator::crate_cache_key("serde", Some("   ")),
378            none
379        );
380
381        // The HTML artifact keyspace inherits the same normalization.
382        assert_eq!(
383            CacheKeyGenerator::crate_html_cache_key("serde", Some("latest")),
384            CacheKeyGenerator::crate_html_cache_key("serde", None)
385        );
386
387        // Item keys normalize identically.
388        let item_none = CacheKeyGenerator::item_cache_key("serde", "Serialize", None);
389        assert_eq!(item_none, "item:serde:Serialize");
390        assert_eq!(
391            CacheKeyGenerator::item_cache_key("serde", "Serialize", Some("latest")),
392            item_none
393        );
394        assert_eq!(
395            CacheKeyGenerator::item_cache_key("serde", "Serialize", Some("")),
396            item_none
397        );
398
399        // A real version is still preserved verbatim (lowercased/trimmed).
400        assert_eq!(
401            CacheKeyGenerator::crate_cache_key("serde", Some("1.0")),
402            "crate:serde:1.0"
403        );
404    }
405
406    #[test]
407    fn test_item_path_case_sensitivity() {
408        assert_ne!(
409            CacheKeyGenerator::item_cache_key("serde", "Serialize", None),
410            CacheKeyGenerator::item_cache_key("serde", "serialize", None)
411        );
412    }
413
414    #[test]
415    fn test_cache_key_edge_cases() {
416        let empty_key = CacheKeyGenerator::crate_cache_key("", None);
417        assert!(empty_key.starts_with("crate:hash:"));
418
419        let whitespace_key = CacheKeyGenerator::crate_cache_key("   ", None);
420        assert!(whitespace_key.starts_with("crate:hash:"));
421
422        // Empty/whitespace-only versions normalize to the unversioned key
423        // (same as `None`) so they do not create duplicate cache entries.
424        assert_eq!(
425            CacheKeyGenerator::crate_cache_key("serde", Some("")),
426            "crate:serde"
427        );
428
429        let unicode_key = CacheKeyGenerator::crate_cache_key("serde测试", None);
430        assert!(unicode_key.starts_with("crate:hash:"));
431        assert!(!unicode_key.contains("测试"));
432
433        let malicious_item_path =
434            CacheKeyGenerator::item_cache_key("serde", "Serialize\nmalicious", None);
435        assert!(malicious_item_path.contains("hash:"));
436        assert!(!malicious_item_path.contains('\n'));
437
438        // Single (non-`::`) colons are ambiguous separators and must be hashed,
439        // not embedded verbatim, to avoid version/path key collisions.
440        let malicious_item_colon =
441            CacheKeyGenerator::item_cache_key("serde", "Serialize:extra:colons", None);
442        assert!(malicious_item_colon.starts_with("item:serde:hash:"));
443        assert!(!malicious_item_colon.contains("Serialize:extra:colons"));
444
445        let valid_item_path = CacheKeyGenerator::item_cache_key("serde", "serde::Serialize", None);
446        assert_eq!(valid_item_path, "item:serde:serde::Serialize");
447
448        let empty_item_key = CacheKeyGenerator::item_cache_key("serde", "", None);
449        assert!(empty_item_key.contains("hash:"));
450
451        let empty_item_crate = CacheKeyGenerator::item_cache_key("", "Crate", None);
452        assert!(empty_item_crate.contains("hash:"));
453    }
454}