Skip to main content

ash_core/
binding.rs

1//! Generic binding normalizer.
2//!
3//! Normalizes any binding value (ip, device, session, user, tenant, route)
4//! with consistent rules: trimming, encoding, control character rejection,
5//! and length enforcement.
6//!
7//! ## Why This Exists
8//!
9//! Previously, SDKs applied ad-hoc normalization to binding values with
10//! inconsistent rules (different trimming, different charset checks).
11//! This module provides a single function that all SDKs call for any
12//! binding type.
13//!
14//! ## Binding Types
15//!
16//! | Type | Example | Description |
17//! |------|---------|-------------|
18//! | `Route` | `POST\|/api/users\|page=1` | HTTP method + path + query |
19//! | `Ip` | `192.168.1.1` | Client IP address |
20//! | `Device` | `device_abc123` | Device identifier |
21//! | `Session` | `sess_xyz789` | Session token |
22//! | `User` | `user@example.com` | User identifier |
23//! | `Tenant` | `tenant_acme` | Multi-tenant identifier |
24//! | `Custom` | any string | Application-defined binding |
25
26use crate::errors::{AshError, AshErrorCode, InternalReason};
27
28/// Maximum allowed length for any binding value.
29pub const MAX_BINDING_VALUE_LENGTH: usize = 8192;
30
31/// Minimum allowed length for a non-empty binding value.
32pub const MIN_BINDING_VALUE_LENGTH: usize = 1;
33
34/// Binding type classification.
35///
36/// Determines which additional validation rules apply beyond the
37/// universal rules (trimming, control char rejection, length check).
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum BindingType {
40    /// HTTP route binding (METHOD|PATH|QUERY format).
41    /// Use `ash_normalize_binding()` for this type — it has specialized logic.
42    Route,
43    /// IP address binding.
44    Ip,
45    /// Device identifier.
46    Device,
47    /// Session identifier.
48    Session,
49    /// User identifier.
50    User,
51    /// Tenant identifier.
52    Tenant,
53    /// Application-defined custom binding.
54    Custom,
55}
56
57impl std::fmt::Display for BindingType {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            BindingType::Route => write!(f, "route"),
61            BindingType::Ip => write!(f, "ip"),
62            BindingType::Device => write!(f, "device"),
63            BindingType::Session => write!(f, "session"),
64            BindingType::User => write!(f, "user"),
65            BindingType::Tenant => write!(f, "tenant"),
66            BindingType::Custom => write!(f, "custom"),
67        }
68    }
69}
70
71/// Result of binding normalization.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct NormalizedBindingValue {
74    /// The normalized value (trimmed, validated)
75    pub value: String,
76
77    /// The binding type that was applied
78    pub binding_type: BindingType,
79
80    /// Original length before trimming
81    pub original_length: usize,
82
83    /// Whether the value was trimmed
84    pub was_trimmed: bool,
85}
86
87/// Normalize a binding value with universal safety rules.
88///
89/// ## Universal Rules (all binding types)
90///
91/// 1. Leading/trailing whitespace is trimmed
92/// 2. Control characters (U+0000–U+001F, U+007F) are rejected
93/// 3. Newlines (`\r`, `\n`) are rejected
94/// 4. NULL bytes are rejected
95/// 5. Empty values (after trimming) are rejected
96/// 6. Values exceeding `MAX_BINDING_VALUE_LENGTH` (8192 bytes) are rejected
97///
98/// ## Type-Specific Rules
99///
100/// - **Route**: Use `ash_normalize_binding()` instead (specialized path/query logic)
101/// - **Ip**: Only ASCII printable characters, no spaces
102/// - **User**: NFC normalization applied
103/// - **All others**: Universal rules only
104///
105/// # Example
106///
107/// ```rust
108/// use ash_core::binding::{ash_normalize_binding_value, BindingType};
109///
110/// let result = ash_normalize_binding_value(BindingType::Ip, "  192.168.1.1  ").unwrap();
111/// assert_eq!(result.value, "192.168.1.1");
112/// assert!(result.was_trimmed);
113///
114/// let result = ash_normalize_binding_value(BindingType::Device, "device_abc123").unwrap();
115/// assert_eq!(result.value, "device_abc123");
116/// assert!(!result.was_trimmed);
117///
118/// // Control characters are rejected
119/// assert!(ash_normalize_binding_value(BindingType::Session, "sess\x00abc").is_err());
120/// ```
121pub fn ash_normalize_binding_value(
122    binding_type: BindingType,
123    value: &str,
124) -> Result<NormalizedBindingValue, AshError> {
125    let original_length = value.len();
126
127    // Rule 1: Trim whitespace
128    let trimmed = value.trim();
129    let was_trimmed = trimmed.len() != original_length;
130
131    // Rule 5: Empty check (after trim)
132    if trimmed.is_empty() {
133        return Err(AshError::with_reason(
134            AshErrorCode::ValidationError,
135            InternalReason::General,
136            format!("Binding value for '{}' cannot be empty", binding_type),
137        ));
138    }
139
140    // Rule 6: Length check
141    if trimmed.len() > MAX_BINDING_VALUE_LENGTH {
142        return Err(AshError::with_reason(
143            AshErrorCode::ValidationError,
144            InternalReason::General,
145            format!(
146                "Binding value for '{}' exceeds maximum length of {} bytes",
147                binding_type, MAX_BINDING_VALUE_LENGTH
148            ),
149        ));
150    }
151
152    // Rules 2-4: Control character / newline / NULL rejection
153    for (i, ch) in trimmed.char_indices() {
154        if ch == '\0' {
155            return Err(AshError::with_reason(
156                AshErrorCode::ValidationError,
157                InternalReason::General,
158                format!(
159                    "Binding value for '{}' contains NULL byte at position {}",
160                    binding_type, i
161                ),
162            ));
163        }
164        if ch == '\r' || ch == '\n' {
165            return Err(AshError::with_reason(
166                AshErrorCode::ValidationError,
167                InternalReason::General,
168                format!(
169                    "Binding value for '{}' contains newline at position {}",
170                    binding_type, i
171                ),
172            ));
173        }
174        if ch.is_control() {
175            return Err(AshError::with_reason(
176                AshErrorCode::ValidationError,
177                InternalReason::General,
178                format!(
179                    "Binding value for '{}' contains control character at position {}",
180                    binding_type, i
181                ),
182            ));
183        }
184    }
185
186    // Type-specific rules
187    match binding_type {
188        BindingType::Route => {
189            return Err(AshError::new(
190                AshErrorCode::ValidationError,
191                "Use ash_normalize_binding() for Route bindings — it has specialized path/query normalization",
192            ));
193        }
194        BindingType::Ip => {
195            // IP addresses must be ASCII printable, no spaces
196            if !trimmed.is_ascii() {
197                return Err(AshError::with_reason(
198                    AshErrorCode::ValidationError,
199                    InternalReason::General,
200                    "IP binding must contain only ASCII characters",
201                ));
202            }
203            if trimmed.contains(' ') {
204                return Err(AshError::with_reason(
205                    AshErrorCode::ValidationError,
206                    InternalReason::General,
207                    "IP binding must not contain spaces",
208                ));
209            }
210        }
211        BindingType::User => {
212            // Apply NFC normalization to user identifiers
213            use unicode_normalization::UnicodeNormalization;
214            let normalized: String = trimmed.nfc().collect();
215            return Ok(NormalizedBindingValue {
216                value: normalized,
217                binding_type,
218                original_length,
219                was_trimmed,
220            });
221        }
222        // Device, Session, Tenant, Custom — universal rules only
223        _ => {}
224    }
225
226    Ok(NormalizedBindingValue {
227        value: trimmed.to_string(),
228        binding_type,
229        original_length,
230        was_trimmed,
231    })
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    // ── Universal rules ───────────────────────────────────────────────
239
240    #[test]
241    fn test_trim_whitespace() {
242        let r = ash_normalize_binding_value(BindingType::Device, "  dev_123  ").unwrap();
243        assert_eq!(r.value, "dev_123");
244        assert!(r.was_trimmed);
245    }
246
247    #[test]
248    fn test_no_trim_needed() {
249        let r = ash_normalize_binding_value(BindingType::Device, "dev_123").unwrap();
250        assert_eq!(r.value, "dev_123");
251        assert!(!r.was_trimmed);
252    }
253
254    #[test]
255    fn test_reject_empty() {
256        assert!(ash_normalize_binding_value(BindingType::Session, "").is_err());
257    }
258
259    #[test]
260    fn test_reject_whitespace_only() {
261        assert!(ash_normalize_binding_value(BindingType::Session, "   ").is_err());
262    }
263
264    #[test]
265    fn test_reject_null_byte() {
266        assert!(ash_normalize_binding_value(BindingType::Device, "dev\x00abc").is_err());
267    }
268
269    #[test]
270    fn test_reject_newline() {
271        assert!(ash_normalize_binding_value(BindingType::Device, "dev\nabc").is_err());
272        assert!(ash_normalize_binding_value(BindingType::Device, "dev\rabc").is_err());
273    }
274
275    #[test]
276    fn test_reject_control_chars() {
277        assert!(ash_normalize_binding_value(BindingType::Device, "dev\x01abc").is_err());
278        assert!(ash_normalize_binding_value(BindingType::Device, "dev\x1Fabc").is_err());
279    }
280
281    #[test]
282    fn test_reject_too_long() {
283        let long = "a".repeat(MAX_BINDING_VALUE_LENGTH + 1);
284        assert!(ash_normalize_binding_value(BindingType::Custom, &long).is_err());
285    }
286
287    #[test]
288    fn test_accept_max_length() {
289        let max = "a".repeat(MAX_BINDING_VALUE_LENGTH);
290        assert!(ash_normalize_binding_value(BindingType::Custom, &max).is_ok());
291    }
292
293    // ── Route type redirects ──────────────────────────────────────────
294
295    #[test]
296    fn test_route_type_rejected() {
297        let err = ash_normalize_binding_value(BindingType::Route, "POST|/api|").unwrap_err();
298        assert!(err.message().contains("ash_normalize_binding"));
299    }
300
301    // ── IP-specific rules ─────────────────────────────────────────────
302
303    #[test]
304    fn test_ip_valid_ipv4() {
305        let r = ash_normalize_binding_value(BindingType::Ip, "192.168.1.1").unwrap();
306        assert_eq!(r.value, "192.168.1.1");
307    }
308
309    #[test]
310    fn test_ip_valid_ipv6() {
311        let r = ash_normalize_binding_value(BindingType::Ip, "::1").unwrap();
312        assert_eq!(r.value, "::1");
313    }
314
315    #[test]
316    fn test_ip_trimmed() {
317        let r = ash_normalize_binding_value(BindingType::Ip, "  10.0.0.1  ").unwrap();
318        assert_eq!(r.value, "10.0.0.1");
319        assert!(r.was_trimmed);
320    }
321
322    #[test]
323    fn test_ip_reject_non_ascii() {
324        assert!(ash_normalize_binding_value(BindingType::Ip, "192.168.١.1").is_err());
325    }
326
327    #[test]
328    fn test_ip_reject_spaces() {
329        assert!(ash_normalize_binding_value(BindingType::Ip, "192.168.1.1 extra").is_err());
330    }
331
332    // ── User-specific rules ───────────────────────────────────────────
333
334    #[test]
335    fn test_user_nfc_normalization() {
336        // e + combining acute accent → é (NFC)
337        let decomposed = "caf\u{0065}\u{0301}";
338        let r = ash_normalize_binding_value(BindingType::User, decomposed).unwrap();
339        assert_eq!(r.value, "café");
340    }
341
342    #[test]
343    fn test_user_already_nfc() {
344        let r = ash_normalize_binding_value(BindingType::User, "user@example.com").unwrap();
345        assert_eq!(r.value, "user@example.com");
346    }
347
348    // ── Binding type metadata ─────────────────────────────────────────
349
350    #[test]
351    fn test_binding_type_preserved() {
352        let r = ash_normalize_binding_value(BindingType::Tenant, "acme").unwrap();
353        assert_eq!(r.binding_type, BindingType::Tenant);
354    }
355
356    #[test]
357    fn test_original_length_tracked() {
358        let r = ash_normalize_binding_value(BindingType::Device, "  abc  ").unwrap();
359        assert_eq!(r.original_length, 7);
360        assert_eq!(r.value, "abc");
361    }
362
363    #[test]
364    fn test_custom_type_accepts_unicode() {
365        let r = ash_normalize_binding_value(BindingType::Custom, "مستخدم").unwrap();
366        assert_eq!(r.value, "مستخدم");
367    }
368
369    #[test]
370    fn test_binding_type_display() {
371        assert_eq!(BindingType::Route.to_string(), "route");
372        assert_eq!(BindingType::Ip.to_string(), "ip");
373        assert_eq!(BindingType::Device.to_string(), "device");
374        assert_eq!(BindingType::Session.to_string(), "session");
375        assert_eq!(BindingType::User.to_string(), "user");
376        assert_eq!(BindingType::Tenant.to_string(), "tenant");
377        assert_eq!(BindingType::Custom.to_string(), "custom");
378    }
379}