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}