1use crate::errors::{AshError, AshErrorCode, InternalReason};
27
28pub const MAX_BINDING_VALUE_LENGTH: usize = 8192;
30
31pub const MIN_BINDING_VALUE_LENGTH: usize = 1;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum BindingType {
40 Route,
43 Ip,
45 Device,
47 Session,
49 User,
51 Tenant,
53 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#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct NormalizedBindingValue {
74 pub value: String,
76
77 pub binding_type: BindingType,
79
80 pub original_length: usize,
82
83 pub was_trimmed: bool,
85}
86
87pub fn ash_normalize_binding_value(
122 binding_type: BindingType,
123 value: &str,
124) -> Result<NormalizedBindingValue, AshError> {
125 let original_length = value.len();
126
127 let trimmed = value.trim();
129 let was_trimmed = trimmed.len() != original_length;
130
131 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 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 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 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 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 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 _ => {}
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 #[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 #[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 #[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 #[test]
335 fn test_user_nfc_normalization() {
336 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 #[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}