ash_core/config/scope_policies.rs
1//! Server-side scope policy registry for ASH.
2//!
3//! This module allows servers to define which fields must be protected for each route,
4//! without requiring client-side scope management. The server can enforce that
5//! specific sensitive fields (like `amount`, `recipient`) are always included in
6//! the ASH proof for particular endpoints.
7//!
8//! ## Overview
9//!
10//! Scope policies answer the question: **"For this endpoint, which fields MUST be protected?"**
11//!
12//! ```text
13//! POST /api/transfer → ["amount", "recipient"] // Must protect these fields
14//! POST /api/payment → ["amount", "card_last4"]
15//! PUT /api/users/* → ["role", "permissions"] // Wildcard pattern
16//! ```
17//!
18//! ## Pattern Syntax
19//!
20//! | Pattern | Matches |
21//! |---------|---------|
22//! | `POST\|/api/users\|` | Exact match |
23//! | `GET\|/api/users/*\|` | Single path segment wildcard |
24//! | `GET\|/api/**\|` | Multiple path segments |
25//! | `*\|/api/users\|` | Any HTTP method |
26//! | `PUT\|/api/users/<id>\|` | Named parameter (Express/Flask style) |
27//! | `PUT\|/api/users/{id}\|` | Named parameter (Laravel style) |
28//!
29//! ## Escape Sequences (BUG-020)
30//!
31//! | Escape | Meaning |
32//! |--------|---------|
33//! | `\\*` | Literal asterisk |
34//! | `\\<` | Literal `<` |
35//! | `\\{` | Literal `{` |
36//! | `\\\\` | Literal backslash |
37//!
38//! ## Example
39//!
40//! ```rust
41//! use ash_core::config::{ash_register_scope_policy, ash_get_scope_policy, ash_clear_scope_policies};
42//!
43//! // Clear any existing policies (useful in tests)
44//! ash_clear_scope_policies();
45//!
46//! // Register policies at application startup
47//! ash_register_scope_policy("POST|/api/transfer|", &["amount", "recipient"]);
48//! ash_register_scope_policy("POST|/api/payment|", &["amount", "card_last4"]);
49//! ash_register_scope_policy("PUT|/api/users/*|", &["role", "permissions"]);
50//!
51//! // Later, get policy for a binding
52//! let scope = ash_get_scope_policy("POST|/api/transfer|");
53//! assert_eq!(scope, vec!["amount", "recipient"]);
54//!
55//! // Wildcard matches
56//! let scope = ash_get_scope_policy("PUT|/api/users/123|");
57//! assert_eq!(scope, vec!["role", "permissions"]);
58//! ```
59//!
60//! ## Pattern Priority (BUG-006)
61//!
62//! When multiple patterns could match a binding, the **first registered pattern wins**.
63//! Register more specific patterns before general ones:
64//!
65//! ```rust,ignore
66//! // CORRECT: Specific first
67//! ash_register_scope_policy("POST|/api/admin/users|", &["all_fields"]);
68//! ash_register_scope_policy("POST|/api/*/users|", &["some_fields"]);
69//!
70//! // WRONG: General pattern would match first
71//! ash_register_scope_policy("POST|/api/*/users|", &["some_fields"]);
72//! ash_register_scope_policy("POST|/api/admin/users|", &["all_fields"]); // Never matches!
73//! ```
74//!
75//! ## Security Properties
76//!
77//! | Property | Description |
78//! |----------|-------------|
79//! | **SEC-001** | Regex complexity limits prevent ReDoS |
80//! | **SEC-003** | RwLock poisoning handled gracefully |
81//! | **SEC-007** | Registration order determinism |
82//! | **SEC-009** | Compiled patterns are cached |
83//! | **BUG-006** | First-registered pattern wins |
84//! | **BUG-020** | Proper escape sequence handling |
85
86use regex::Regex;
87use std::collections::{BTreeMap, HashMap};
88use std::sync::RwLock;
89
90/// Maximum allowed pattern length to prevent ReDoS attacks.
91const MAX_PATTERN_LENGTH: usize = 512;
92
93/// Maximum allowed wildcards in a pattern to prevent exponential backtracking.
94const MAX_WILDCARDS: usize = 8;
95
96/// Wildcard characters that have special meaning in patterns.
97const WILDCARD_CHARS: &[char] = &['*', '<', ':', '{'];
98
99/// Check if a wildcard character at position `i` is escaped.
100/// BUG-020: Properly counts consecutive backslashes to handle `\\*` (escaped backslash + wildcard).
101/// An odd number of preceding backslashes means the wildcard is escaped.
102/// An even number means the backslashes are paired (escaped) and the wildcard is unescaped.
103fn ash_is_wildcard_escaped(chars: &[char], i: usize) -> bool {
104 if i == 0 {
105 return false;
106 }
107 // Count consecutive backslashes before this position
108 let mut backslash_count = 0;
109 let mut pos = i - 1;
110 loop {
111 if chars[pos] == '\\' {
112 backslash_count += 1;
113 if pos == 0 {
114 break;
115 }
116 pos -= 1;
117 } else {
118 break;
119 }
120 }
121 // Odd number of backslashes = wildcard is escaped
122 // Even number = backslashes are paired, wildcard is NOT escaped
123 backslash_count % 2 == 1
124}
125
126/// Check if a pattern has any unescaped wildcards.
127fn ash_has_unescaped_wildcard(pattern: &str) -> bool {
128 let chars: Vec<char> = pattern.chars().collect();
129 for (i, &ch) in chars.iter().enumerate() {
130 if WILDCARD_CHARS.contains(&ch) {
131 // BUG-020: Use proper escape detection
132 if !ash_is_wildcard_escaped(&chars, i) {
133 return true;
134 }
135 }
136 }
137 false
138}
139
140/// Count unescaped wildcards in a pattern.
141fn ash_count_unescaped_wildcards(pattern: &str) -> usize {
142 let chars: Vec<char> = pattern.chars().collect();
143 let mut count = 0;
144 for (i, &ch) in chars.iter().enumerate() {
145 if WILDCARD_CHARS.contains(&ch) {
146 // BUG-020: Use proper escape detection
147 if !ash_is_wildcard_escaped(&chars, i) {
148 count += 1;
149 }
150 }
151 }
152 count
153}
154
155/// Unescape a pattern by removing escape backslashes.
156/// BUG-020: Properly handles `\\\\*` (two backslashes + asterisk) as `\*` (literal backslash + literal asterisk).
157/// - `\\` (escaped backslash) becomes `\`
158/// - `\*` (escaped wildcard) becomes `*`
159/// - `\\\\*` becomes `\*` (one literal backslash, one literal asterisk)
160fn ash_unescape_pattern(pattern: &str) -> String {
161 let mut result = String::with_capacity(pattern.len());
162 let chars: Vec<char> = pattern.chars().collect();
163 let mut i = 0;
164 while i < chars.len() {
165 if chars[i] == '\\' && i + 1 < chars.len() {
166 let next = chars[i + 1];
167 if next == '\\' {
168 // Escaped backslash: \\ -> \
169 result.push('\\');
170 i += 2;
171 } else if WILDCARD_CHARS.contains(&next) {
172 // Escaped wildcard: \* -> *
173 result.push(next);
174 i += 2;
175 } else {
176 // Backslash not followed by escapable char: keep as-is
177 result.push(chars[i]);
178 i += 1;
179 }
180 } else {
181 result.push(chars[i]);
182 i += 1;
183 }
184 }
185 result
186}
187
188/// Compiled pattern for efficient matching.
189#[derive(Debug, Clone)]
190struct CompiledPattern {
191 /// Original pattern string
192 pattern: String,
193 /// Compiled regex (None if pattern has no wildcards)
194 regex: Option<Regex>,
195 /// Whether this is an exact match pattern
196 is_exact: bool,
197}
198
199impl CompiledPattern {
200 /// Compile a pattern into a matcher.
201 /// Returns None if the pattern is invalid or too complex.
202 ///
203 /// # Escape Sequences (BUG-010)
204 ///
205 /// Use `\\*` to match a literal asterisk, `\\<` for literal `<`, etc.
206 /// Escaped wildcards don't count toward the wildcard limit.
207 fn compile(pattern: &str) -> Option<Self> {
208 // SEC-001: Limit pattern length to prevent ReDoS
209 if pattern.len() > MAX_PATTERN_LENGTH {
210 return None;
211 }
212
213 // BUG-010: Check for unescaped wildcards only
214 // Count wildcards that are NOT escaped (not preceded by \)
215 let has_unescaped_wildcards = ash_has_unescaped_wildcard(pattern);
216
217 if !has_unescaped_wildcards {
218 // Pattern may have escaped wildcards - exact matching will unescape
219 return Some(CompiledPattern {
220 pattern: pattern.to_string(),
221 regex: None,
222 is_exact: true,
223 });
224 }
225
226 // SEC-001: Count unescaped wildcards to prevent exponential backtracking
227 let wildcard_count = ash_count_unescaped_wildcards(pattern);
228
229 if wildcard_count > MAX_WILDCARDS {
230 return None;
231 }
232
233 // Build regex pattern safely
234 let regex = ash_build_safe_regex(pattern)?;
235
236 Some(CompiledPattern {
237 pattern: pattern.to_string(),
238 regex: Some(regex),
239 is_exact: false,
240 })
241 }
242
243 /// Check if a binding matches this pattern.
244 fn matches(&self, binding: &str) -> bool {
245 if self.is_exact {
246 // BUG-010: For exact match, compare against unescaped pattern
247 return binding == ash_unescape_pattern(&self.pattern);
248 }
249
250 if let Some(ref regex) = self.regex {
251 regex.is_match(binding)
252 } else {
253 false
254 }
255 }
256}
257
258/// Build a safe regex from a pattern, preventing ReDoS.
259///
260/// # BUG-010 & BUG-020: Escape Sequences
261///
262/// Handles escape sequences properly:
263/// - `\\` (escaped backslash) becomes literal `\`
264/// - `\*` (escaped asterisk) becomes literal `*`
265/// - `\\*` (escaped backslash + asterisk) becomes literal `\` + wildcard match
266fn ash_build_safe_regex(pattern: &str) -> Option<Regex> {
267 // Use pre-compiled static patterns for replacements
268 // These patterns match on the ESCAPED regex string
269 // After regex::escape: < stays <, : stays :, { becomes \{, } becomes \}
270 lazy_static::lazy_static! {
271 static ref FLASK_RE: Regex = Regex::new(r"<[a-zA-Z_][a-zA-Z0-9_]*>").unwrap();
272 static ref EXPRESS_RE: Regex = Regex::new(r":[a-zA-Z_][a-zA-Z0-9_]*").unwrap();
273 // In escaped regex, { becomes \{ and } becomes \}
274 // So we need to match the escaped version: \\\{id\\\}
275 static ref LARAVEL_RE: Regex = Regex::new(r"\\\{[a-zA-Z_][a-zA-Z0-9_]*\\\}").unwrap();
276 }
277
278 // BUG-020: Process escape sequences properly by iterating through chars
279 // We need to handle: \\ -> literal backslash, \* -> literal *, etc.
280 let placeholder_backslash = "\x00ESCAPED_BACKSLASH\x00";
281 let placeholder_star = "\x00ESCAPED_STAR\x00";
282 let placeholder_lt = "\x00ESCAPED_LT\x00";
283 let placeholder_colon = "\x00ESCAPED_COLON\x00";
284 let placeholder_lbrace = "\x00ESCAPED_LBRACE\x00";
285
286 let mut temp = String::with_capacity(pattern.len() * 2);
287 let chars: Vec<char> = pattern.chars().collect();
288 let mut i = 0;
289 while i < chars.len() {
290 if chars[i] == '\\' && i + 1 < chars.len() {
291 let next = chars[i + 1];
292 if next == '\\' {
293 // Escaped backslash: \\ -> placeholder for literal backslash
294 temp.push_str(placeholder_backslash);
295 i += 2;
296 } else if next == '*' {
297 temp.push_str(placeholder_star);
298 i += 2;
299 } else if next == '<' {
300 temp.push_str(placeholder_lt);
301 i += 2;
302 } else if next == ':' {
303 temp.push_str(placeholder_colon);
304 i += 2;
305 } else if next == '{' {
306 temp.push_str(placeholder_lbrace);
307 i += 2;
308 } else {
309 temp.push(chars[i]);
310 i += 1;
311 }
312 } else {
313 temp.push(chars[i]);
314 i += 1;
315 }
316 }
317
318 // Now escape for regex
319 let mut regex_str = regex::escape(&temp);
320
321 // Replace ** first (multi-segment) - use non-greedy and limit depth
322 // SEC-001: Use [^|]* instead of .* to prevent catastrophic backtracking
323 regex_str = regex_str.replace(r"\*\*", "[^|]*");
324
325 // Replace * (single segment - not containing | or /)
326 regex_str = regex_str.replace(r"\*", "[^|/]*");
327
328 // Replace route params with bounded character class
329 regex_str = FLASK_RE.replace_all(®ex_str, "[^|/]+").to_string();
330 regex_str = EXPRESS_RE.replace_all(®ex_str, "[^|/]+").to_string();
331 regex_str = LARAVEL_RE.replace_all(®ex_str, "[^|/]+").to_string();
332
333 // BUG-020: Restore escaped characters as literals
334 regex_str = regex_str.replace(®ex::escape(placeholder_backslash), r"\\");
335 regex_str = regex_str.replace(®ex::escape(placeholder_star), r"\*");
336 regex_str = regex_str.replace(®ex::escape(placeholder_lt), "<");
337 regex_str = regex_str.replace(®ex::escape(placeholder_colon), ":");
338 regex_str = regex_str.replace(®ex::escape(placeholder_lbrace), r"\{");
339
340 // SEC-001: Use RegexBuilder with size limit to prevent complex patterns
341 regex::RegexBuilder::new(&format!("^{}$", regex_str))
342 .size_limit(10 * 1024) // 10KB limit on compiled regex size
343 .build()
344 .ok()
345}
346
347/// Scope policy registry that can be instantiated for isolated testing.
348///
349/// For most use cases, use the global functions (`ash_register_scope_policy`, etc.)
350/// which operate on a shared global registry.
351///
352/// # Pattern Priority (BUG-006 fix)
353///
354/// When multiple patterns could match a binding, the **first registered pattern wins**.
355/// This uses registration order, not alphabetical order, for deterministic matching.
356#[derive(Debug, Default)]
357pub struct ScopePolicyRegistry {
358 /// Policies stored in registration order for pattern matching priority.
359 /// BUG-006: Uses Vec to preserve registration order.
360 policies_ordered: Vec<(String, CompiledPattern, Vec<String>)>,
361 /// Fast lookup for exact match bindings.
362 exact_matches: HashMap<String, usize>, // Maps unescaped pattern -> index in policies_ordered
363}
364
365impl ScopePolicyRegistry {
366 /// Create a new empty registry.
367 pub fn new() -> Self {
368 Self {
369 policies_ordered: Vec::new(),
370 exact_matches: HashMap::new(),
371 }
372 }
373
374 /// Register a scope policy for a binding pattern.
375 ///
376 /// Returns false if the pattern is invalid or too complex.
377 ///
378 /// # Pattern Priority (BUG-006)
379 ///
380 /// Later registrations for the same pattern will replace earlier ones.
381 /// When matching, the first registered pattern that matches wins.
382 pub fn register(&mut self, binding: &str, fields: &[&str]) -> bool {
383 if let Some(compiled) = CompiledPattern::compile(binding) {
384 let fields_vec: Vec<String> = fields.iter().map(|s| s.to_string()).collect();
385
386 // Check if this exact pattern was already registered
387 let existing_idx = self.policies_ordered.iter().position(|(p, _, _)| p == binding);
388
389 if let Some(idx) = existing_idx {
390 // MED-001 FIX: Remove old entry from exact_matches if it existed
391 // The old pattern may have been exact while new one is wildcard (or vice versa)
392 let old_unescaped = ash_unescape_pattern(binding);
393 self.exact_matches.remove(&old_unescaped);
394
395 // Update existing entry
396 self.policies_ordered[idx] = (binding.to_string(), compiled.clone(), fields_vec);
397
398 // MED-001 FIX: Re-add to exact_matches if new pattern is exact
399 if compiled.is_exact {
400 let unescaped = ash_unescape_pattern(binding);
401 self.exact_matches.insert(unescaped, idx);
402 }
403 } else {
404 // Add new entry
405 let idx = self.policies_ordered.len();
406 self.policies_ordered.push((binding.to_string(), compiled.clone(), fields_vec));
407
408 // BUG-010: For exact matches, store the unescaped pattern in lookup
409 if compiled.is_exact {
410 let unescaped = ash_unescape_pattern(binding);
411 self.exact_matches.insert(unescaped, idx);
412 }
413 }
414 true
415 } else {
416 false
417 }
418 }
419
420 /// Register multiple scope policies at once.
421 ///
422 /// Returns the number of successfully registered policies.
423 ///
424 /// # Pattern Priority (BUG-034 Warning)
425 ///
426 /// **Important**: BTreeMap iterates in **alphabetical order** by key, not insertion order.
427 /// When you call this method, patterns are registered in alphabetical order of their
428 /// binding strings.
429 ///
430 /// If you need specific registration order for pattern priority, use multiple calls
431 /// to `register()` instead, or use a `Vec` of tuples and iterate manually.
432 ///
433 /// # Example
434 ///
435 /// ```rust,ignore
436 /// // These will be registered in alphabetical order:
437 /// // 1. "GET|/api/*|"
438 /// // 2. "GET|/api/users|"
439 /// // 3. "POST|/api/*|"
440 /// // NOT the order you insert them into the BTreeMap!
441 /// ```
442 pub fn register_many(&mut self, policies_map: &BTreeMap<&str, Vec<&str>>) -> usize {
443 let mut count = 0;
444 for (binding, fields) in policies_map {
445 if self.register(binding, fields) {
446 count += 1;
447 }
448 }
449 count
450 }
451
452 /// Get the scope policy for a binding.
453 ///
454 /// # Pattern Priority (BUG-006 fix)
455 ///
456 /// Uses registration order - earlier registered patterns take precedence.
457 pub fn get(&self, binding: &str) -> Vec<String> {
458 // Fast path: exact match lookup
459 if let Some(&idx) = self.exact_matches.get(binding) {
460 if idx < self.policies_ordered.len() {
461 return self.policies_ordered[idx].2.clone();
462 }
463 }
464
465 // BUG-006: Iterate in registration order, first match wins
466 for (_, compiled, fields) in &self.policies_ordered {
467 if compiled.matches(binding) {
468 return fields.clone();
469 }
470 }
471
472 // Default: no scoping (full payload protection)
473 Vec::new()
474 }
475
476 /// Check if a binding has a scope policy defined.
477 pub fn has(&self, binding: &str) -> bool {
478 // Fast path: exact match lookup
479 if self.exact_matches.contains_key(binding) {
480 return true;
481 }
482
483 // Check pattern matches in registration order
484 for (_, compiled, _) in &self.policies_ordered {
485 if compiled.matches(binding) {
486 return true;
487 }
488 }
489
490 false
491 }
492
493 /// Get all registered policies.
494 ///
495 /// Returns policies in registration order.
496 pub fn get_all(&self) -> BTreeMap<String, Vec<String>> {
497 self.policies_ordered
498 .iter()
499 .map(|(pattern, _, fields)| (pattern.clone(), fields.clone()))
500 .collect()
501 }
502
503 /// Clear all registered policies.
504 pub fn clear(&mut self) {
505 self.policies_ordered.clear();
506 self.exact_matches.clear();
507 }
508}
509
510lazy_static::lazy_static! {
511 /// Global scope policy registry
512 static ref GLOBAL_REGISTRY: RwLock<ScopePolicyRegistry> = RwLock::new(ScopePolicyRegistry::new());
513}
514
515/// Safely acquire write lock, recovering from poison if needed.
516/// SEC-003: Handle RwLock poisoning gracefully.
517fn ash_get_write_lock() -> std::sync::RwLockWriteGuard<'static, ScopePolicyRegistry> {
518 GLOBAL_REGISTRY
519 .write()
520 .unwrap_or_else(|poisoned| {
521 // Recover from poison by taking the inner value
522 // This allows the system to continue after a panic
523 poisoned.into_inner()
524 })
525}
526
527/// Safely acquire read lock, recovering from poison if needed.
528/// SEC-003: Handle RwLock poisoning gracefully.
529fn ash_get_read_lock() -> std::sync::RwLockReadGuard<'static, ScopePolicyRegistry> {
530 GLOBAL_REGISTRY
531 .read()
532 .unwrap_or_else(|poisoned| {
533 // Recover from poison by taking the inner value
534 poisoned.into_inner()
535 })
536}
537
538/// Register a scope policy for a binding pattern.
539///
540/// # Arguments
541///
542/// * `binding` - The binding pattern (supports `<param>`, `:param`, `{param}`, `*`, `**` wildcards)
543/// * `fields` - The fields that must be protected
544///
545/// # Returns
546///
547/// `true` if the policy was registered, `false` if the pattern is invalid or too complex.
548///
549/// # Security Limits
550///
551/// - Maximum pattern length: 512 characters
552/// - Maximum wildcards: 8 per pattern
553///
554/// # Example
555///
556/// ```rust
557/// use ash_core::config::{ash_register_scope_policy, ash_clear_scope_policies};
558///
559/// ash_clear_scope_policies();
560/// assert!(ash_register_scope_policy("POST|/api/transfer|", &["amount", "recipient"]));
561/// assert!(ash_register_scope_policy("PUT|/api/users/<id>|", &["role", "permissions"]));
562/// ```
563pub fn ash_register_scope_policy(binding: &str, fields: &[&str]) -> bool {
564 let mut registry = ash_get_write_lock();
565 registry.register(binding, fields)
566}
567
568/// Register multiple scope policies at once.
569///
570/// # Arguments
571///
572/// * `policies` - Map of binding => fields
573///
574/// # Returns
575///
576/// Number of successfully registered policies.
577///
578/// # Pattern Priority (BUG-034 Warning)
579///
580/// **Important**: BTreeMap iterates in **alphabetical order** by key, not insertion order.
581/// Patterns are registered in alphabetical order of their binding strings.
582///
583/// If you need specific registration order for pattern priority (since "first registered
584/// pattern wins"), use multiple calls to `ash_register_scope_policy()` instead.
585///
586/// # Example
587///
588/// ```rust
589/// use std::collections::BTreeMap;
590/// use ash_core::config::{ash_register_scope_policies, ash_clear_scope_policies};
591///
592/// ash_clear_scope_policies();
593/// let mut policies = BTreeMap::new();
594/// policies.insert("POST|/api/transfer|", vec!["amount", "recipient"]);
595/// policies.insert("POST|/api/payment|", vec!["amount", "card_last4"]);
596/// assert_eq!(ash_register_scope_policies(&policies), 2);
597/// // Note: These are registered in alphabetical order!
598/// ```
599pub fn ash_register_scope_policies(policies_map: &BTreeMap<&str, Vec<&str>>) -> usize {
600 let mut registry = ash_get_write_lock();
601 registry.register_many(policies_map)
602}
603
604/// Get the scope policy for a binding.
605///
606/// Returns empty vector if no policy is defined (full payload protection).
607///
608/// # Arguments
609///
610/// * `binding` - The normalized binding string
611///
612/// # Returns
613///
614/// The fields that must be protected
615///
616/// # Example
617///
618/// ```rust
619/// use ash_core::config::{ash_register_scope_policy, ash_get_scope_policy, ash_clear_scope_policies};
620///
621/// ash_clear_scope_policies();
622/// ash_register_scope_policy("POST|/api/transfer|", &["amount", "recipient"]);
623///
624/// let scope = ash_get_scope_policy("POST|/api/transfer|");
625/// assert_eq!(scope, vec!["amount", "recipient"]);
626///
627/// let no_scope = ash_get_scope_policy("GET|/api/users|");
628/// assert!(no_scope.is_empty());
629/// ```
630pub fn ash_get_scope_policy(binding: &str) -> Vec<String> {
631 let registry = ash_get_read_lock();
632 registry.get(binding)
633}
634
635/// Check if a binding has a scope policy defined.
636///
637/// # Arguments
638///
639/// * `binding` - The normalized binding string
640///
641/// # Returns
642///
643/// True if a policy exists
644pub fn ash_has_scope_policy(binding: &str) -> bool {
645 let registry = ash_get_read_lock();
646 registry.has(binding)
647}
648
649/// Get all registered policies.
650///
651/// # Returns
652///
653/// All registered scope policies in deterministic order.
654pub fn ash_get_all_scope_policies() -> BTreeMap<String, Vec<String>> {
655 let registry = ash_get_read_lock();
656 registry.get_all()
657}
658
659/// Clear all registered policies.
660///
661/// Useful for testing.
662pub fn ash_clear_scope_policies() {
663 let mut registry = ash_get_write_lock();
664 registry.clear();
665}
666
667// =========================================================================
668// Deprecated Aliases for Backward Compatibility
669// =========================================================================
670
671// =========================================================================
672// Deprecated Aliases for Backward Compatibility (Non-conforming names)
673// =========================================================================
674
675/// Register a scope policy for a binding pattern.
676///
677/// # Deprecated
678///
679/// Use [`ash_register_scope_policy`] instead. This alias is provided for backward compatibility.
680/// Note: This function name does not follow the `ash_` prefix convention and will be removed in v3.0.
681#[deprecated(since = "2.4.0", note = "Use ash_register_scope_policy instead")]
682pub fn register_scope_policy(binding: &str, fields: &[&str]) -> bool {
683 ash_register_scope_policy(binding, fields)
684}
685
686/// Register multiple scope policies at once.
687///
688/// # Deprecated
689///
690/// Use [`ash_register_scope_policies`] instead. This alias is provided for backward compatibility.
691/// Note: This function name does not follow the `ash_` prefix convention and will be removed in v3.0.
692#[deprecated(since = "2.4.0", note = "Use ash_register_scope_policies instead")]
693pub fn register_scope_policies(policies_map: &BTreeMap<&str, Vec<&str>>) -> usize {
694 ash_register_scope_policies(policies_map)
695}
696
697/// Get the scope policy for a binding.
698///
699/// # Deprecated
700///
701/// Use [`ash_get_scope_policy`] instead. This alias is provided for backward compatibility.
702/// Note: This function name does not follow the `ash_` prefix convention and will be removed in v3.0.
703#[deprecated(since = "2.4.0", note = "Use ash_get_scope_policy instead")]
704pub fn get_scope_policy(binding: &str) -> Vec<String> {
705 ash_get_scope_policy(binding)
706}
707
708/// Check if a binding has a scope policy defined.
709///
710/// # Deprecated
711///
712/// Use [`ash_has_scope_policy`] instead. This alias is provided for backward compatibility.
713/// Note: This function name does not follow the `ash_` prefix convention and will be removed in v3.0.
714#[deprecated(since = "2.4.0", note = "Use ash_has_scope_policy instead")]
715pub fn has_scope_policy(binding: &str) -> bool {
716 ash_has_scope_policy(binding)
717}
718
719/// Get all registered policies.
720///
721/// # Deprecated
722///
723/// Use [`ash_get_all_scope_policies`] instead. This alias is provided for backward compatibility.
724/// Note: This function name does not follow the `ash_` prefix convention and will be removed in v3.0.
725#[deprecated(since = "2.4.0", note = "Use ash_get_all_scope_policies instead")]
726pub fn get_all_scope_policies() -> BTreeMap<String, Vec<String>> {
727 ash_get_all_scope_policies()
728}
729
730/// Clear all registered policies.
731///
732/// # Deprecated
733///
734/// Use [`ash_clear_scope_policies`] instead. This alias is provided for backward compatibility.
735/// Note: This function name does not follow the `ash_` prefix convention and will be removed in v3.0.
736#[deprecated(since = "2.4.0", note = "Use ash_clear_scope_policies instead")]
737pub fn clear_scope_policies() {
738 ash_clear_scope_policies()
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744
745 // Tests using isolated ScopePolicyRegistry instances - can run in parallel
746
747 #[test]
748 fn test_registry_register_and_get() {
749 let mut registry = ScopePolicyRegistry::new();
750 assert!(registry.register("POST|/api/transfer|", &["amount", "recipient"]));
751
752 let scope = registry.get("POST|/api/transfer|");
753 assert_eq!(scope, vec!["amount", "recipient"]);
754 }
755
756 #[test]
757 fn test_registry_get_no_match() {
758 let registry = ScopePolicyRegistry::new();
759
760 let scope = registry.get("GET|/api/users|");
761 assert!(scope.is_empty());
762 }
763
764 #[test]
765 fn test_registry_has() {
766 let mut registry = ScopePolicyRegistry::new();
767 registry.register("POST|/api/transfer|", &["amount"]);
768
769 assert!(registry.has("POST|/api/transfer|"));
770 assert!(!registry.has("GET|/api/users|"));
771 }
772
773 #[test]
774 fn test_registry_pattern_matching_flask_style() {
775 let mut registry = ScopePolicyRegistry::new();
776 registry.register("PUT|/api/users/<id>|", &["role", "permissions"]);
777
778 let scope = registry.get("PUT|/api/users/123|");
779 assert_eq!(scope, vec!["role", "permissions"]);
780 }
781
782 #[test]
783 fn test_registry_pattern_matching_express_style() {
784 let mut registry = ScopePolicyRegistry::new();
785 registry.register("PUT|/api/users/:id|", &["role"]);
786
787 let scope = registry.get("PUT|/api/users/456|");
788 assert_eq!(scope, vec!["role"]);
789 }
790
791 #[test]
792 fn test_registry_pattern_matching_laravel_style() {
793 let mut registry = ScopePolicyRegistry::new();
794 registry.register("PUT|/api/users/{id}|", &["email"]);
795
796 let scope = registry.get("PUT|/api/users/789|");
797 assert_eq!(scope, vec!["email"]);
798 }
799
800 #[test]
801 fn test_registry_pattern_matching_wildcard() {
802 let mut registry = ScopePolicyRegistry::new();
803 registry.register("POST|/api/*/transfer|", &["amount"]);
804
805 let scope = registry.get("POST|/api/v1/transfer|");
806 assert_eq!(scope, vec!["amount"]);
807 }
808
809 #[test]
810 fn test_registry_pattern_matching_double_wildcard() {
811 let mut registry = ScopePolicyRegistry::new();
812 registry.register("POST|/api/**/transfer|", &["amount"]);
813
814 let scope = registry.get("POST|/api/v1/users/transfer|");
815 assert_eq!(scope, vec!["amount"]);
816 }
817
818 #[test]
819 fn test_registry_clear() {
820 let mut registry = ScopePolicyRegistry::new();
821 registry.register("POST|/api/transfer|", &["amount"]);
822
823 assert!(registry.has("POST|/api/transfer|"));
824
825 registry.clear();
826
827 assert!(!registry.has("POST|/api/transfer|"));
828 }
829
830 #[test]
831 fn test_registry_register_many() {
832 let mut registry = ScopePolicyRegistry::new();
833 let mut policies = BTreeMap::new();
834 policies.insert("POST|/api/transfer|", vec!["amount"]);
835 policies.insert("POST|/api/payment|", vec!["card"]);
836
837 let count = registry.register_many(&policies);
838 assert_eq!(count, 2);
839
840 assert!(registry.has("POST|/api/transfer|"));
841 assert!(registry.has("POST|/api/payment|"));
842 }
843
844 #[test]
845 fn test_registry_get_all() {
846 let mut registry = ScopePolicyRegistry::new();
847 registry.register("POST|/api/transfer|", &["amount"]);
848 registry.register("POST|/api/payment|", &["card"]);
849
850 let all = registry.get_all();
851 assert_eq!(all.len(), 2);
852 }
853
854 // Security tests
855
856 #[test]
857 fn test_rejects_pattern_too_long() {
858 let mut registry = ScopePolicyRegistry::new();
859 let long_pattern = "POST|/api/".to_string() + &"a".repeat(600) + "|";
860
861 // Should reject pattern that's too long (SEC-001)
862 assert!(!registry.register(&long_pattern, &["field"]));
863 }
864
865 #[test]
866 fn test_rejects_too_many_wildcards() {
867 let mut registry = ScopePolicyRegistry::new();
868 let many_wildcards = "POST|/*/*/*/*/*/*/*/*/*|"; // 9 wildcards
869
870 // Should reject pattern with too many wildcards (SEC-001)
871 assert!(!registry.register(many_wildcards, &["field"]));
872 }
873
874 #[test]
875 fn test_accepts_valid_wildcards() {
876 let mut registry = ScopePolicyRegistry::new();
877 let valid_pattern = "POST|/*/*/*/*/*/*/*|"; // 7 wildcards (within limit)
878
879 // Should accept pattern within limits
880 assert!(registry.register(valid_pattern, &["field"]));
881 }
882
883 // BUG-020: Escape sequence handling tests
884
885 #[test]
886 fn test_escaped_backslash_before_wildcard() {
887 let mut registry = ScopePolicyRegistry::new();
888 // Pattern: \\* means literal backslash + wildcard (match any)
889 // This should match "POST|/api\anything|"
890 assert!(registry.register(r"POST|/api\\*|", &["field"]));
891
892 // Should match path with literal backslash followed by anything
893 let scope = registry.get(r"POST|/api\test|");
894 assert_eq!(scope, vec!["field"]);
895
896 let scope2 = registry.get(r"POST|/api\foo|");
897 assert_eq!(scope2, vec!["field"]);
898 }
899
900 #[test]
901 fn test_escaped_asterisk_exact_match() {
902 let mut registry = ScopePolicyRegistry::new();
903 // Pattern: \* means literal asterisk (exact match)
904 assert!(registry.register(r"POST|/api/\*|", &["field"]));
905
906 // Should match path with literal asterisk
907 let scope = registry.get("POST|/api/*|");
908 assert_eq!(scope, vec!["field"]);
909
910 // Should NOT match other paths
911 let scope2 = registry.get("POST|/api/test|");
912 assert!(scope2.is_empty());
913 }
914
915 #[test]
916 fn test_double_escaped_backslash() {
917 let mut registry = ScopePolicyRegistry::new();
918 // Pattern: \\\\ means two literal backslashes (exact match)
919 assert!(registry.register(r"POST|/api/\\\\test|", &["field"]));
920
921 // Should match path with two literal backslashes
922 let scope = registry.get(r"POST|/api/\\test|");
923 assert_eq!(scope, vec!["field"]);
924 }
925
926 #[test]
927 fn test_is_wildcard_escaped_helper() {
928 // Test the helper function directly
929 let chars1: Vec<char> = r"\*".chars().collect();
930 assert!(ash_is_wildcard_escaped(&chars1, 1)); // * is escaped
931
932 let chars2: Vec<char> = r"\\*".chars().collect();
933 assert!(!ash_is_wildcard_escaped(&chars2, 2)); // * is NOT escaped (backslash is escaped)
934
935 let chars3: Vec<char> = r"\\\*".chars().collect();
936 assert!(ash_is_wildcard_escaped(&chars3, 3)); // * is escaped
937
938 let chars4: Vec<char> = "*".chars().collect();
939 assert!(!ash_is_wildcard_escaped(&chars4, 0)); // * is NOT escaped (no preceding backslash)
940 }
941}