Skip to main content

api_scanner/scanner/
cve_templates.rs

1use async_trait::async_trait;
2use dashmap::DashSet;
3use rand::seq::SliceRandom;
4use regex::Regex;
5use reqwest::{
6    header::{HeaderMap, HeaderName, HeaderValue},
7    Method,
8};
9use serde::Deserialize;
10use std::{collections::HashSet, fs, path::Path, sync::Arc};
11use tracing::{error, info, warn};
12use url::Url;
13
14use crate::{
15    config::Config,
16    error::CapturedError,
17    http_client::{HttpClient, HttpResponse},
18    reports::{Finding, Severity},
19};
20
21use super::Scanner;
22
23pub struct CveTemplateScanner {
24    templates: Arc<Vec<CveTemplate>>,
25    checked_host_templates: Arc<DashSet<String>>,
26}
27
28impl CveTemplateScanner {
29    pub fn new(config: &Config) -> Self {
30        Self {
31            templates: Arc::new(load_templates(config.quiet)),
32            checked_host_templates: Arc::new(DashSet::new()),
33        }
34    }
35}
36
37#[derive(Debug, Clone, Deserialize)]
38struct CveTemplateFile {
39    #[serde(default)]
40    templates: Vec<CveTemplate>,
41}
42
43#[derive(Debug, Clone, Deserialize)]
44struct NameValue {
45    name: String,
46    value: String,
47}
48
49#[derive(Debug, Clone, Deserialize)]
50struct TemplateRequestStep {
51    path: String,
52    #[serde(default = "default_method")]
53    method: String,
54    #[serde(default)]
55    headers: Vec<NameValue>,
56    #[serde(default)]
57    expect_status_any_of: Vec<u16>,
58}
59
60#[derive(Debug, Clone, Deserialize)]
61struct CveTemplate {
62    id: String,
63    check: String,
64    title: String,
65    severity: String,
66    detail: String,
67    remediation: String,
68    source: String,
69    path: String,
70    #[serde(default = "default_method")]
71    method: String,
72    #[serde(default)]
73    headers: Vec<NameValue>,
74    #[serde(default)]
75    preflight_requests: Vec<TemplateRequestStep>,
76    #[serde(default)]
77    match_headers: Vec<NameValue>,
78    #[serde(default)]
79    status_any_of: Vec<u16>,
80    #[serde(default)]
81    body_contains_any: Vec<String>,
82    #[serde(default)]
83    body_contains_all: Vec<String>,
84    #[serde(default)]
85    body_regex_any: Vec<String>,
86    #[serde(default)]
87    body_regex_all: Vec<String>,
88    #[serde(default)]
89    header_regex_any: Vec<String>,
90    #[serde(default)]
91    header_regex_all: Vec<String>,
92    #[serde(default)]
93    context_path_contains_any: Vec<String>,
94    #[serde(default)]
95    baseline_status_any_of: Vec<u16>,
96    #[serde(default)]
97    baseline_body_contains_any: Vec<String>,
98    #[serde(default)]
99    baseline_body_contains_all: Vec<String>,
100    #[serde(default)]
101    baseline_match_headers: Vec<NameValue>,
102}
103
104fn default_method() -> String {
105    "GET".to_string()
106}
107
108fn load_templates(quiet: bool) -> Vec<CveTemplate> {
109    let mut templates = Vec::new();
110    let mut seen_checks = HashSet::new();
111    let mut skipped_invalid = 0usize;
112    let mut skipped_unsafe = 0usize;
113    let mut skipped_invalid_templates = Vec::new();
114    let mut skipped_unsafe_templates = Vec::new();
115
116    for dir in cve_template_dirs() {
117        if !dir.exists() || !dir.is_dir() {
118            continue;
119        }
120
121        let mut entries = match fs::read_dir(&dir) {
122            Ok(rd) => rd
123                .filter_map(|e| e.ok())
124                .map(|e| e.path())
125                .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("toml"))
126                .collect::<Vec<_>>(),
127            Err(e) => {
128                warn!(
129                    template_dir = %dir.display(),
130                    error = %e,
131                    "Failed to read CVE template directory"
132                );
133                Vec::new()
134            }
135        };
136
137        entries.sort();
138
139        for path in entries {
140            let raw = match fs::read_to_string(&path) {
141                Ok(s) => s,
142                Err(e) => {
143                    warn!(
144                        template_path = %path.display(),
145                        error = %e,
146                        "Failed to read CVE template file"
147                    );
148                    continue;
149                }
150            };
151
152            let parsed = match toml::from_str::<CveTemplateFile>(&raw) {
153                Ok(v) => v,
154                Err(e) => {
155                    warn!(
156                        template_path = %path.display(),
157                        error = %e,
158                        "Failed to parse CVE template file"
159                    );
160                    continue;
161                }
162            };
163
164            for mut t in parsed.templates {
165                let template_key = format!("{} ({})", template_identity(&t.id), path.display());
166                if t.id.trim().is_empty()
167                    || t.check.trim().is_empty()
168                    || t.path.trim().is_empty()
169                    || t.method.trim().is_empty()
170                {
171                    skipped_invalid += 1;
172                    skipped_invalid_templates
173                        .push(format!("{template_key}: missing id/check/path/method"));
174                    warn!(
175                        template_path = %path.display(),
176                        "Skipping invalid CVE template: id/check/path/method required"
177                    );
178                    continue;
179                }
180
181                if !is_supported_template_method(&t.method) {
182                    skipped_invalid += 1;
183                    skipped_invalid_templates
184                        .push(format!("{template_key}: unsupported method '{}'", t.method));
185                    warn!(
186                        template_path = %path.display(),
187                        template_id = %t.id,
188                        method = %t.method,
189                        "Skipping CVE template with unsupported method"
190                    );
191                    continue;
192                }
193
194                if !t.path.trim().starts_with('/') {
195                    skipped_invalid += 1;
196                    skipped_invalid_templates.push(format!(
197                        "{template_key}: non-root-relative path '{}'",
198                        t.path
199                    ));
200                    warn!(
201                        template_path = %path.display(),
202                        template_id = %t.id,
203                        template_path_value = %t.path,
204                        "Skipping CVE template with non-root-relative path"
205                    );
206                    continue;
207                }
208
209                if has_unresolved_request_placeholder(&t.path)
210                    || headers_have_unresolved_placeholders(&t.headers)
211                {
212                    skipped_unsafe += 1;
213                    skipped_unsafe_templates.push(format!(
214                        "{template_key}: unresolved placeholder in path/headers"
215                    ));
216                    continue;
217                }
218
219                let mut invalid_preflight = false;
220                t.preflight_requests.retain(|step| {
221                    if step.path.trim().is_empty() || step.method.trim().is_empty() {
222                        return false;
223                    }
224                    if !step.path.trim().starts_with('/') {
225                        invalid_preflight = true;
226                        return false;
227                    }
228                    if !is_supported_template_method(&step.method)
229                        || has_unresolved_request_placeholder(&step.path)
230                        || headers_have_unresolved_placeholders(&step.headers)
231                    {
232                        invalid_preflight = true;
233                        return false;
234                    }
235                    true
236                });
237
238                if invalid_preflight {
239                    skipped_unsafe += 1;
240                    skipped_unsafe_templates.push(format!(
241                        "{template_key}: invalid/unsafe preflight request metadata"
242                    ));
243                    continue;
244                }
245
246                let has_status_matcher = !t.status_any_of.is_empty();
247                let has_evidence_matchers = template_has_response_evidence_matchers(&t);
248                if !has_status_matcher && !has_evidence_matchers {
249                    skipped_unsafe += 1;
250                    skipped_unsafe_templates
251                        .push(format!("{template_key}: no response matchers configured"));
252                    continue;
253                }
254                if has_status_matcher && !has_evidence_matchers {
255                    skipped_unsafe += 1;
256                    skipped_unsafe_templates.push(format!(
257                        "{template_key}: status-only matcher without body/header evidence"
258                    ));
259                    continue;
260                }
261
262                if is_root_probe_path(&t.path) {
263                    if !t.context_path_contains_any.is_empty() {
264                        info!(
265                            template_path = %path.display(),
266                            template_id = %t.id,
267                            "Ignoring context_path_contains_any for root-path template"
268                        );
269                    }
270                    t.context_path_contains_any.clear();
271                } else {
272                    t.context_path_contains_any =
273                        normalize_context_hints(&t.context_path_contains_any);
274                    if t.context_path_contains_any.is_empty() {
275                        t.context_path_contains_any = derive_context_hints_from_path(&t.path);
276                    }
277                    if t.context_path_contains_any.is_empty() {
278                        skipped_invalid += 1;
279                        skipped_invalid_templates
280                            .push(format!("{template_key}: empty/invalid context hints"));
281                        warn!(
282                            template_path = %path.display(),
283                            template_id = %t.id,
284                            "Skipping CVE template with empty/invalid context hints"
285                        );
286                        continue;
287                    }
288                }
289
290                if t.source.trim().is_empty() {
291                    t.source = format!("apihunter:{}", path.display());
292                }
293
294                if !seen_checks.insert(t.check.to_ascii_lowercase()) {
295                    continue;
296                }
297
298                templates.push(t);
299            }
300        }
301    }
302
303    if templates.is_empty() {
304        warn!("No CVE templates loaded from configured template directories");
305    }
306    if skipped_invalid > 0 || skipped_unsafe > 0 {
307        warn!(
308            skipped_invalid,
309            skipped_unsafe,
310            loaded = templates.len(),
311            "CVE template loader skipped invalid/unsafe templates"
312        );
313
314        if quiet {
315            if !skipped_invalid_templates.is_empty() {
316                error!(
317                    skipped_invalid,
318                    templates = ?skipped_invalid_templates,
319                    "CVE template loader invalid-template details"
320                );
321            }
322            if !skipped_unsafe_templates.is_empty() {
323                error!(
324                    skipped_unsafe,
325                    templates = ?skipped_unsafe_templates,
326                    "CVE template loader unsafe-template details"
327                );
328            }
329        } else {
330            if !skipped_invalid_templates.is_empty() {
331                info!(
332                    skipped_invalid,
333                    templates = ?skipped_invalid_templates,
334                    "CVE template loader invalid-template details"
335                );
336            }
337            if !skipped_unsafe_templates.is_empty() {
338                info!(
339                    skipped_unsafe,
340                    templates = ?skipped_unsafe_templates,
341                    "CVE template loader unsafe-template details"
342                );
343            }
344        }
345    }
346
347    templates
348}
349
350fn cve_template_dirs() -> Vec<std::path::PathBuf> {
351    let mut dirs = vec![Path::new("assets/cve_templates").to_path_buf()];
352
353    if let Ok(extra) = std::env::var("APIHUNTER_CVE_TEMPLATE_DIRS") {
354        for raw in extra.split(':') {
355            let trimmed = raw.trim();
356            if trimmed.is_empty() {
357                continue;
358            }
359            dirs.push(Path::new(trimmed).to_path_buf());
360        }
361    }
362
363    dirs
364}
365
366#[async_trait]
367impl Scanner for CveTemplateScanner {
368    fn name(&self) -> &'static str {
369        "cve_templates"
370    }
371
372    async fn scan(
373        &self,
374        url: &str,
375        client: &HttpClient,
376        config: &Config,
377    ) -> (Vec<Finding>, Vec<CapturedError>) {
378        if !config.active_checks || self.templates.is_empty() {
379            return (Vec::new(), Vec::new());
380        }
381
382        let mut findings = Vec::new();
383        let mut errors = Vec::new();
384
385        let parsed = match Url::parse(url) {
386            Ok(u) if matches!(u.scheme(), "http" | "https") => u,
387            _ => return (findings, errors),
388        };
389
390        let Some(host) = parsed.host_str() else {
391            return (findings, errors);
392        };
393        let host = host.to_ascii_lowercase();
394        let target_path = parsed.path().to_ascii_lowercase();
395
396        let mut base = format!("{}://{}", parsed.scheme(), host);
397        if let Some(port) = parsed.port() {
398            base.push(':');
399            base.push_str(&port.to_string());
400        }
401
402        let mut ordered_templates = self.templates.iter().collect::<Vec<_>>();
403        if ordered_templates.len() > 1 {
404            let mut rng = rand::thread_rng();
405            ordered_templates.shuffle(&mut rng);
406        }
407
408        for tmpl in ordered_templates {
409            if !template_context_matches(tmpl, &target_path) {
410                continue;
411            }
412
413            let key = format!("{host}|{}", tmpl.id.to_ascii_lowercase());
414            if !self.checked_host_templates.insert(key) {
415                continue;
416            }
417
418            let probe_url = if tmpl.path.starts_with('/') {
419                format!("{base}{}", tmpl.path)
420            } else {
421                format!("{base}/{}", tmpl.path)
422            };
423
424            match execute_preflight_chain(client, &base, tmpl).await {
425                Ok(true) => {}
426                Ok(false) => continue,
427                Err(e) => {
428                    errors.push(e);
429                    continue;
430                }
431            }
432
433            if template_has_baseline_matchers(tmpl) {
434                let baseline_resp = match client.get(&probe_url).await {
435                    Ok(r) => r,
436                    Err(e) => {
437                        errors.push(e);
438                        continue;
439                    }
440                };
441                if !template_matches_baseline_response(tmpl, &baseline_resp) {
442                    continue;
443                }
444            }
445
446            let resp = match execute_template_request(
447                client,
448                &probe_url,
449                &tmpl.method,
450                &tmpl.headers,
451                &tmpl.id,
452            )
453            .await
454            {
455                Ok(r) => r,
456                Err(e) => {
457                    errors.push(e);
458                    continue;
459                }
460            };
461
462            if !template_matches_response(tmpl, &resp) {
463                continue;
464            }
465
466            findings.push(
467                Finding::new(
468                    &probe_url,
469                    &tmpl.check,
470                    &tmpl.title,
471                    parse_severity(&tmpl.severity),
472                    format!("{}\nTemplate source: {}", tmpl.detail, tmpl.source),
473                    "cve_templates",
474                )
475                .with_evidence(format!(
476                    "Template: {}\nMethod: {}\nURL: {}\nStatus: {}\nBody snippet: {}",
477                    tmpl.id,
478                    tmpl.method,
479                    probe_url,
480                    resp.status,
481                    snippet(&resp.body, 280)
482                ))
483                .with_remediation(&tmpl.remediation),
484            );
485        }
486
487        (findings, errors)
488    }
489}
490
491fn template_context_matches(tmpl: &CveTemplate, target_path: &str) -> bool {
492    if tmpl.context_path_contains_any.is_empty() {
493        return true;
494    }
495
496    let target_segments = path_segments(target_path);
497    if target_segments.is_empty() {
498        return false;
499    }
500
501    let mut specific_hints = Vec::new();
502    let mut generic_hints = Vec::new();
503
504    for hint in &tmpl.context_path_contains_any {
505        let hint_segments = path_segments(hint);
506        if hint_segments.is_empty() {
507            continue;
508        }
509
510        if hint_segments.len() == 1 && is_generic_context_token(hint_segments[0]) {
511            generic_hints.push(hint_segments);
512        } else {
513            specific_hints.push(hint_segments);
514        }
515    }
516
517    let candidate_hints = if !specific_hints.is_empty() {
518        specific_hints
519    } else {
520        generic_hints
521    };
522
523    if candidate_hints.is_empty() {
524        return false;
525    }
526
527    candidate_hints
528        .iter()
529        .any(|hint| contains_segment_sequence(&target_segments, hint))
530}
531
532fn template_has_baseline_matchers(tmpl: &CveTemplate) -> bool {
533    !tmpl.baseline_status_any_of.is_empty()
534        || !tmpl.baseline_body_contains_any.is_empty()
535        || !tmpl.baseline_body_contains_all.is_empty()
536        || !tmpl.baseline_match_headers.is_empty()
537}
538
539async fn execute_template_request(
540    client: &HttpClient,
541    probe_url: &str,
542    method_raw: &str,
543    headers_raw: &[NameValue],
544    template_id: &str,
545) -> Result<HttpResponse, CapturedError> {
546    let method = method_raw.to_ascii_uppercase();
547
548    if !matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS") {
549        return Err(CapturedError::internal(format!(
550            "Unsupported CVE template method '{}' for {}",
551            method_raw, template_id
552        )));
553    }
554
555    let parsed_method = Method::from_bytes(method.as_bytes()).map_err(|e| {
556        CapturedError::internal(format!(
557            "Invalid CVE template method '{}' for {}: {}",
558            method_raw, template_id, e
559        ))
560    })?;
561
562    let headers = to_header_map(headers_raw);
563    let extra = if headers.is_empty() {
564        None
565    } else {
566        Some(headers)
567    };
568    client.request(parsed_method, probe_url, extra, None).await
569}
570
571async fn execute_preflight_chain(
572    client: &HttpClient,
573    base: &str,
574    tmpl: &CveTemplate,
575) -> Result<bool, CapturedError> {
576    for step in &tmpl.preflight_requests {
577        let url = build_template_url(base, &step.path);
578        let resp =
579            execute_template_request(client, &url, &step.method, &step.headers, &tmpl.id).await?;
580        if !step.expect_status_any_of.is_empty()
581            && !step.expect_status_any_of.contains(&resp.status)
582        {
583            return Ok(false);
584        }
585    }
586    Ok(true)
587}
588
589fn build_template_url(base: &str, path: &str) -> String {
590    if path.starts_with('/') {
591        format!("{base}{path}")
592    } else {
593        format!("{base}/{path}")
594    }
595}
596
597fn to_header_map(pairs: &[NameValue]) -> HeaderMap {
598    let mut map = HeaderMap::new();
599    for pair in pairs {
600        if let (Ok(name), Ok(value)) = (
601            HeaderName::from_bytes(pair.name.as_bytes()),
602            HeaderValue::from_str(&pair.value),
603        ) {
604            map.insert(name, value);
605        }
606    }
607    map
608}
609
610struct ResponseMatchConstraints<'a> {
611    status_any_of: &'a [u16],
612    body_contains_any: &'a [String],
613    body_contains_all: &'a [String],
614    body_regex_any: &'a [String],
615    body_regex_all: &'a [String],
616    match_headers: &'a [NameValue],
617    header_regex_any: &'a [String],
618    header_regex_all: &'a [String],
619}
620
621fn template_matches_response(tmpl: &CveTemplate, resp: &HttpResponse) -> bool {
622    let constraints = ResponseMatchConstraints {
623        status_any_of: &tmpl.status_any_of,
624        body_contains_any: &tmpl.body_contains_any,
625        body_contains_all: &tmpl.body_contains_all,
626        body_regex_any: &tmpl.body_regex_any,
627        body_regex_all: &tmpl.body_regex_all,
628        match_headers: &tmpl.match_headers,
629        header_regex_any: &tmpl.header_regex_any,
630        header_regex_all: &tmpl.header_regex_all,
631    };
632    response_matches_constraints(&constraints, resp)
633}
634
635fn template_matches_baseline_response(tmpl: &CveTemplate, resp: &HttpResponse) -> bool {
636    let constraints = ResponseMatchConstraints {
637        status_any_of: &tmpl.baseline_status_any_of,
638        body_contains_any: &tmpl.baseline_body_contains_any,
639        body_contains_all: &tmpl.baseline_body_contains_all,
640        body_regex_any: &[],
641        body_regex_all: &[],
642        match_headers: &tmpl.baseline_match_headers,
643        header_regex_any: &[],
644        header_regex_all: &[],
645    };
646    response_matches_constraints(&constraints, resp)
647}
648
649fn response_matches_constraints(
650    constraints: &ResponseMatchConstraints,
651    resp: &HttpResponse,
652) -> bool {
653    if !constraints.status_any_of.is_empty() && !constraints.status_any_of.contains(&resp.status) {
654        return false;
655    }
656
657    let body_l = resp.body.to_ascii_lowercase();
658
659    if !constraints.body_contains_all.is_empty()
660        && !constraints
661            .body_contains_all
662            .iter()
663            .all(|needle| body_l.contains(&needle.to_ascii_lowercase()))
664    {
665        return false;
666    }
667
668    if !constraints.body_contains_any.is_empty()
669        && !constraints
670            .body_contains_any
671            .iter()
672            .any(|needle| body_l.contains(&needle.to_ascii_lowercase()))
673    {
674        return false;
675    }
676
677    if !constraints.body_regex_all.is_empty()
678        && !constraints
679            .body_regex_all
680            .iter()
681            .all(|pattern| regex_matches(pattern, &resp.body))
682    {
683        return false;
684    }
685
686    if !constraints.body_regex_any.is_empty()
687        && !constraints
688            .body_regex_any
689            .iter()
690            .any(|pattern| regex_matches(pattern, &resp.body))
691    {
692        return false;
693    }
694
695    if !constraints.match_headers.is_empty() {
696        for hv in constraints.match_headers {
697            let name_l = hv.name.to_ascii_lowercase();
698            let want_l = hv.value.to_ascii_lowercase();
699            let got = resp.headers.get(&name_l).map(|v| v.to_ascii_lowercase());
700            if got.as_ref().map(|v| !v.contains(&want_l)).unwrap_or(true) {
701                return false;
702            }
703        }
704    }
705
706    if !constraints.header_regex_all.is_empty() || !constraints.header_regex_any.is_empty() {
707        let header_blob = resp
708            .headers
709            .iter()
710            .map(|(k, v)| format!("{k}: {v}\n"))
711            .collect::<String>();
712
713        if !constraints.header_regex_all.is_empty()
714            && !constraints
715                .header_regex_all
716                .iter()
717                .all(|pattern| regex_matches(pattern, &header_blob))
718        {
719            return false;
720        }
721        if !constraints.header_regex_any.is_empty()
722            && !constraints
723                .header_regex_any
724                .iter()
725                .any(|pattern| regex_matches(pattern, &header_blob))
726        {
727            return false;
728        }
729    }
730
731    true
732}
733
734fn regex_matches(pattern: &str, haystack: &str) -> bool {
735    Regex::new(pattern)
736        .map(|re| re.is_match(haystack))
737        .unwrap_or(false)
738}
739
740fn parse_severity(s: &str) -> Severity {
741    match s.trim().to_ascii_lowercase().as_str() {
742        "critical" => Severity::Critical,
743        "high" => Severity::High,
744        "medium" => Severity::Medium,
745        "low" => Severity::Low,
746        _ => Severity::Info,
747    }
748}
749
750fn is_supported_template_method(method: &str) -> bool {
751    matches!(
752        method.trim().to_ascii_uppercase().as_str(),
753        "GET" | "HEAD" | "OPTIONS"
754    )
755}
756
757fn has_unresolved_request_placeholder(raw: &str) -> bool {
758    let v = raw.trim();
759    v.contains("{{") && v.contains("}}")
760}
761
762fn template_identity(id: &str) -> &str {
763    let trimmed = id.trim();
764    if trimmed.is_empty() {
765        "<missing-id>"
766    } else {
767        trimmed
768    }
769}
770
771fn template_has_response_evidence_matchers(tmpl: &CveTemplate) -> bool {
772    !tmpl.body_contains_any.is_empty()
773        || !tmpl.body_contains_all.is_empty()
774        || !tmpl.body_regex_any.is_empty()
775        || !tmpl.body_regex_all.is_empty()
776        || !tmpl.match_headers.is_empty()
777        || !tmpl.header_regex_any.is_empty()
778        || !tmpl.header_regex_all.is_empty()
779}
780
781fn headers_have_unresolved_placeholders(headers: &[NameValue]) -> bool {
782    headers.iter().any(|h| {
783        has_unresolved_request_placeholder(&h.name) || has_unresolved_request_placeholder(&h.value)
784    })
785}
786
787fn normalize_context_hints(raw_hints: &[String]) -> Vec<String> {
788    let mut out = Vec::new();
789    let mut seen = HashSet::new();
790
791    for raw in raw_hints {
792        let trimmed = raw.trim();
793        if trimmed.is_empty() {
794            continue;
795        }
796        if let Some(normalized) = normalize_hint(trimmed) {
797            if seen.insert(normalized.clone()) {
798                out.push(normalized);
799            }
800        }
801    }
802
803    out
804}
805
806fn is_root_probe_path(path: &str) -> bool {
807    path_segments(path).is_empty()
808}
809
810fn derive_context_hints_from_path(path: &str) -> Vec<String> {
811    let mut out = Vec::new();
812    let mut seen = HashSet::new();
813    for token in path_segments(path) {
814        if token.starts_with('{') || token.contains("{{") || token.contains("}}") {
815            continue;
816        }
817        let hint = format!("/{token}");
818        if seen.insert(hint.clone()) {
819            out.push(hint);
820        }
821        if out.len() >= 4 {
822            break;
823        }
824    }
825    out
826}
827
828fn normalize_hint(raw: &str) -> Option<String> {
829    let canonical = raw.split('?').next().unwrap_or(raw).to_ascii_lowercase();
830    let segments = path_segments(&canonical);
831    if segments.is_empty() {
832        return None;
833    }
834    if segments
835        .iter()
836        .any(|s| s.contains("{{") || s.contains("}}"))
837    {
838        return None;
839    }
840    Some(format!("/{}", segments.join("/")))
841}
842
843fn path_segments(path: &str) -> Vec<&str> {
844    path.split('?')
845        .next()
846        .unwrap_or(path)
847        .split('/')
848        .filter_map(|seg| {
849            let trimmed = seg.trim();
850            if trimmed.is_empty() {
851                None
852            } else {
853                Some(trimmed)
854            }
855        })
856        .collect()
857}
858
859fn is_generic_context_token(token: &str) -> bool {
860    matches!(
861        token,
862        "api"
863            | "apis"
864            | "rest"
865            | "v1"
866            | "v2"
867            | "v3"
868            | "v4"
869            | "v5"
870            | "latest"
871            | "public"
872            | "internal"
873            | "service"
874            | "services"
875            | "console"
876            | "ui"
877            | "web"
878            | "www"
879            | "app"
880            | "apps"
881            | "default"
882            | "index"
883    )
884}
885
886fn contains_segment_sequence(target: &[&str], hint: &[&str]) -> bool {
887    if hint.len() > target.len() {
888        return false;
889    }
890    target.windows(hint.len()).any(|win| win == hint)
891}
892
893fn snippet(s: &str, max_chars: usize) -> String {
894    let mut out = s.chars().take(max_chars).collect::<String>();
895    if s.chars().count() > max_chars {
896        out.push_str("...");
897    }
898    out
899}