ash_core/lib.rs
1//! # ASH Core
2//!
3//! **ASH (Anti-tamper Security Hash)** is a request integrity and anti-replay protection library
4//!
5//! ## Safety
6//!
7//! This crate uses `#![forbid(unsafe_code)]` to guarantee 100% safe Rust.
8//! that ensures HTTP requests have not been tampered with in transit.
9//!
10//! ## What ASH Does
11//!
12//! ASH provides cryptographic proof that:
13//! - The **payload** has not been modified
14//! - The request is for the **correct endpoint** (method + path + query)
15//! - The request is **not a replay** of a previous request
16//! - Optionally, only **specific fields** are protected (scoping)
17//!
18//! ## What ASH Does NOT Do
19//!
20//! ASH verifies **what** is being submitted, not **who** is submitting it.
21//! Use alongside authentication systems (JWT, OAuth, API keys, etc.).
22//!
23//! ## Quick Start
24//!
25//! ```rust
26//! use ash_core::{
27//! ash_canonicalize_json, ash_derive_client_secret,
28//! ash_build_proof, ash_verify_proof, ash_hash_body,
29//! };
30//!
31//! // 1. Server provides nonce and context_id to client
32//! let nonce = "0123456789abcdef0123456789abcdef"; // 32+ hex chars
33//! let context_id = "ctx_abc123";
34//! let binding = "POST|/api/transfer|";
35//!
36//! // 2. Client canonicalizes payload
37//! let payload = r#"{"amount":100,"recipient":"alice"}"#;
38//! let canonical = ash_canonicalize_json(payload).unwrap();
39//!
40//! // 3. Client derives secret and builds proof
41//! let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
42//! let body_hash = ash_hash_body(&canonical);
43//! let timestamp = "1704067200";
44//! let proof = ash_build_proof(&client_secret, timestamp, binding, &body_hash).unwrap();
45//!
46//! // 4. Server verifies proof (re-derives secret from nonce internally)
47//! let valid = ash_verify_proof(nonce, context_id, binding, timestamp, &body_hash, &proof).unwrap();
48//! assert!(valid);
49//! ```
50//!
51//! ## Features
52//!
53//! | Feature | Description |
54//! |---------|-------------|
55//! | **Tamper Detection** | HMAC-SHA256 proof ensures payload integrity |
56//! | **Replay Prevention** | One-time contexts prevent request replay |
57//! | **Deterministic** | Byte-identical output across all platforms |
58//! | **Field Scoping** | Protect specific fields while allowing others to change |
59//! | **Request Chaining** | Link sequential requests cryptographically |
60//! | **WASM Compatible** | Works in browsers and server environments |
61//!
62//! ## Module Overview
63//!
64//! | Module | Purpose |
65//! |--------|---------|
66//! | [`proof`](crate::proof) | Core proof generation and verification |
67//! | [`canonicalize`](crate::canonicalize) | Deterministic JSON/URL-encoded serialization |
68//! | [`compare`](crate::compare) | Constant-time comparison functions |
69//! | [`config`](crate::config) | Scope policy configuration |
70//! | [`errors`](crate::errors) | Error types and codes |
71//!
72//! ## Security Considerations
73//!
74//! - **Nonce entropy**: Use 32+ hex characters (128+ bits) for nonces
75//! - **Timestamp validation**: Reject requests older than 5 minutes
76//! - **HTTPS required**: ASH does not encrypt data, only signs it
77//! - **Context isolation**: Never reuse context_id across requests
78//!
79//! ## Protocol Version
80//!
81//! This library implements ASH Protocol v2.1 with extensions:
82//! - v2.2: Field-level scoping
83//! - v2.3: Request chaining
84//! - v2.3.2: Binding normalization (METHOD|PATH|QUERY format)
85//! - v2.3.4: Bug fixes (BUG-020 through BUG-034)
86//! - v2.3.5: Security hardening (SEC-AUDIT-005 through SEC-AUDIT-007)
87
88#![forbid(unsafe_code)]
89#![forbid(clippy::undocumented_unsafe_blocks)]
90
91mod canonicalize;
92mod compare;
93pub mod config;
94mod errors;
95pub mod headers;
96mod proof;
97mod types;
98pub mod binding;
99pub mod enriched;
100mod validate;
101pub mod build;
102pub mod testkit;
103pub mod verify;
104
105pub use canonicalize::{ash_canonicalize_json, ash_canonicalize_json_value, ash_canonicalize_json_value_with_size_check, ash_canonicalize_query, ash_canonicalize_urlencoded};
106pub use compare::{ash_timing_safe_equal, ash_timing_safe_compare};
107pub use errors::{AshError, AshErrorCode, InternalReason};
108pub use headers::{HeaderMapView, HeaderBundle, ash_extract_headers};
109pub use validate::ash_validate_nonce;
110pub use proof::{
111 // Core proof functions
112 ash_build_proof,
113 ash_verify_proof,
114 ash_verify_proof_with_freshness,
115 ash_derive_client_secret,
116 // Scoped proof functions
117 ash_build_proof_scoped,
118 ash_verify_proof_scoped,
119 ash_extract_scoped_fields,
120 ash_extract_scoped_fields_strict,
121 // Unified proof functions (scoping + chaining)
122 ash_build_proof_unified,
123 ash_verify_proof_unified,
124 UnifiedProofResult,
125 // Hash functions
126 ash_hash_body,
127 ash_hash_proof,
128 ash_hash_scope,
129 ash_hash_scoped_body,
130 ash_hash_scoped_body_strict,
131 // Nonce and context generation
132 ash_generate_nonce,
133 ash_generate_nonce_or_panic,
134 ash_generate_context_id,
135 ash_generate_context_id_256,
136 // Timestamp validation
137 ash_validate_timestamp,
138 ash_validate_timestamp_format,
139 DEFAULT_MAX_TIMESTAMP_AGE_SECONDS,
140 DEFAULT_CLOCK_SKEW_SECONDS,
141 // Version constants
142 ASH_SDK_VERSION,
143 ASH_VERSION_PREFIX,
144};
145pub use types::{AshMode, BuildProofInput, VerifyInput};
146pub use binding::{ash_normalize_binding_value, BindingType, NormalizedBindingValue, MAX_BINDING_VALUE_LENGTH};
147pub use build::{build_request_proof, BuildRequestInput, BuildRequestResult, BuildMeta};
148pub use enriched::{
149 ash_canonicalize_query_enriched, CanonicalQueryResult,
150 ash_hash_body_enriched, BodyHashResult,
151 ash_normalize_binding_enriched, ash_parse_binding, NormalizedBinding,
152};
153pub use testkit::{load_vectors, load_vectors_from_file, run_vectors, AshAdapter, AdapterResult, TestReport, VectorResult, Vector, VectorFile};
154pub use verify::{verify_incoming_request, VerifyRequestInput, VerifyResult, VerifyMeta};
155
156/// Normalize a binding string to canonical form (v2.3.2+ format).
157///
158/// Bindings are in the format: `METHOD|PATH|CANONICAL_QUERY`
159///
160/// # Normalization Rules
161/// - Method is uppercased
162/// - Path must start with `/`
163/// - Path must not contain `?` (use `normalize_binding_from_url` for combined path+query)
164/// - Path is percent-decoded, normalized, then re-encoded (BUG-025 fix)
165/// - Path has duplicate slashes collapsed (after decoding)
166/// - Trailing slash is removed (except for root `/`)
167/// - Query string is canonicalized (sorted, normalized)
168/// - Parts are joined with `|` (pipe) separator
169///
170/// # Path Normalization (BUG-025)
171///
172/// Paths are decoded before normalization to handle cases like:
173/// - `/api/%2F%2F/users` → decoded → `/api///users` → normalized → `/api/users`
174/// - `/api/caf%C3%A9` → decoded → `/api/café` → re-encoded → `/api/caf%C3%A9`
175///
176/// # Error on Embedded Query
177///
178/// If the `path` parameter contains a `?`, an error is returned to prevent
179/// silent data loss. Use [`normalize_binding_from_url`] if you have a combined
180/// path+query string.
181///
182/// # Example
183///
184/// ```rust
185/// use ash_core::ash_normalize_binding;
186///
187/// let binding = ash_normalize_binding("post", "/api//users/", "").unwrap();
188/// assert_eq!(binding, "POST|/api/users|");
189///
190/// let binding_with_query = ash_normalize_binding("GET", "/api/users", "page=1&sort=name").unwrap();
191/// assert_eq!(binding_with_query, "GET|/api/users|page=1&sort=name");
192///
193/// // Error if path contains '?'
194/// assert!(ash_normalize_binding("GET", "/api/users?old=query", "new=query").is_err());
195/// ```
196pub fn ash_normalize_binding(method: &str, path: &str, query: &str) -> Result<String, AshError> {
197 // Validate method
198 let method = method.trim();
199 if method.is_empty() {
200 return Err(AshError::new(
201 AshErrorCode::ValidationError,
202 "Method cannot be empty",
203 ));
204 }
205
206 // BUG-042: Use ASCII-only uppercase to ensure cross-platform consistency
207 // Unicode uppercase rules can vary across platforms/versions
208 if !method.is_ascii() {
209 return Err(AshError::new(
210 AshErrorCode::ValidationError,
211 "Method must contain only ASCII characters",
212 ));
213 }
214 let method = method.to_ascii_uppercase();
215
216 // Validate path starts with /
217 let path = path.trim();
218 if !path.starts_with('/') {
219 return Err(AshError::new(
220 AshErrorCode::ValidationError,
221 "Path must start with /",
222 ));
223 }
224
225 // BUG-025: Percent-decode the path before normalization
226 let decoded_path = ash_percent_decode_path(path)?;
227
228 // BUG-009 & BUG-027: Error if path contains '?' AFTER decoding to catch encoded %3F
229 // This prevents silent data loss and encoded query delimiter bypass
230 if decoded_path.contains('?') {
231 return Err(AshError::new(
232 AshErrorCode::ValidationError,
233 "Path must not contain '?' (including encoded %3F) - use normalize_binding_from_url for combined path+query",
234 ));
235 }
236
237 // BUG-035: Normalize path segments including . and ..
238 let normalized_path = ash_normalize_path_segments(&decoded_path);
239
240 // Ensure path still starts with / after normalization
241 if normalized_path.is_empty() || !normalized_path.starts_with('/') {
242 return Err(AshError::new(
243 AshErrorCode::ValidationError,
244 "Path normalization resulted in invalid path",
245 ));
246 }
247
248 // BUG-025: Re-encode the normalized path (only encode characters that need encoding)
249 let encoded_path = ash_percent_encode_path(&normalized_path);
250
251 // BUG-043: Trim whitespace from query string before canonicalization
252 // Whitespace-only query should be treated as empty
253 let query = query.trim();
254 let canonical_query = if query.is_empty() {
255 String::new()
256 } else {
257 canonicalize::ash_canonicalize_query(query)?
258 };
259
260 // v2.3.2 format: METHOD|PATH|CANONICAL_QUERY
261 Ok(format!(
262 "{}|{}|{}",
263 method, encoded_path, canonical_query
264 ))
265}
266
267/// Percent-decode a URL path segment.
268/// BUG-025: Decodes %XX sequences to their character equivalents.
269fn ash_percent_decode_path(input: &str) -> Result<String, AshError> {
270 let mut bytes = Vec::with_capacity(input.len());
271 let mut chars = input.chars().peekable();
272
273 while let Some(ch) = chars.next() {
274 if ch == '%' {
275 // Read two hex digits
276 let hex: String = chars.by_ref().take(2).collect();
277 if hex.len() != 2 {
278 return Err(AshError::new(
279 AshErrorCode::ValidationError,
280 "Invalid percent encoding in path",
281 ));
282 }
283 let byte = u8::from_str_radix(&hex, 16).map_err(|_| {
284 AshError::new(
285 AshErrorCode::ValidationError,
286 "Invalid percent encoding hex in path",
287 )
288 })?;
289 bytes.push(byte);
290 } else {
291 // Encode character directly to UTF-8 bytes
292 let mut buf = [0u8; 4];
293 let encoded = ch.encode_utf8(&mut buf);
294 bytes.extend_from_slice(encoded.as_bytes());
295 }
296 }
297
298 // Convert bytes to UTF-8 string
299 String::from_utf8(bytes).map_err(|_| {
300 AshError::new(
301 AshErrorCode::ValidationError,
302 "Invalid UTF-8 in percent-decoded path",
303 )
304 })
305}
306
307/// Normalize path segments, handling `.`, `..`, duplicate slashes, and trailing slashes.
308/// BUG-035: Properly resolves `.` (current dir) and `..` (parent dir) segments.
309///
310/// # Rules
311/// - `.` segments are removed
312/// - `..` segments remove the preceding segment (if any)
313/// - Duplicate slashes are collapsed
314/// - Trailing slash is removed (except for root `/`)
315/// - `..` at root level is ignored (can't go above root)
316fn ash_normalize_path_segments(path: &str) -> String {
317 let mut segments: Vec<&str> = Vec::new();
318
319 for segment in path.split('/') {
320 match segment {
321 "" | "." => {
322 // Empty segment (from // or leading /) or current dir - skip
323 continue;
324 }
325 ".." => {
326 // Parent dir - pop last segment if any
327 segments.pop();
328 }
329 s => {
330 segments.push(s);
331 }
332 }
333 }
334
335 // Reconstruct path with leading slash
336 if segments.is_empty() {
337 "/".to_string()
338 } else {
339 format!("/{}", segments.join("/"))
340 }
341}
342
343/// Percent-encode a URL path, preserving safe characters.
344/// BUG-025: Only encodes characters that are not allowed in URL paths.
345fn ash_percent_encode_path(input: &str) -> String {
346 let mut result = String::with_capacity(input.len() * 3);
347
348 for ch in input.chars() {
349 match ch {
350 // Unreserved characters (RFC 3986)
351 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
352 result.push(ch);
353 }
354 // Path separators and sub-delimiters that are safe in paths
355 '/' | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '=' | ':' | '@' => {
356 result.push(ch);
357 }
358 _ => {
359 // Encode all other characters
360 let mut buf = [0u8; 4];
361 let encoded = ch.encode_utf8(&mut buf);
362 for byte in encoded.as_bytes() {
363 use std::fmt::Write;
364 write!(result, "%{:02X}", byte).unwrap();
365 }
366 }
367 }
368 }
369
370 result
371}
372
373/// Normalize a binding from a full URL path (including query string).
374///
375/// This is a convenience function that extracts the query string from the path.
376///
377/// # Example
378///
379/// ```rust
380/// use ash_core::ash_normalize_binding_from_url;
381///
382/// let binding = ash_normalize_binding_from_url("GET", "/api/users?page=1&sort=name").unwrap();
383/// assert_eq!(binding, "GET|/api/users|page=1&sort=name");
384/// ```
385pub fn ash_normalize_binding_from_url(method: &str, full_path: &str) -> Result<String, AshError> {
386 let (path, query) = match full_path.find('?') {
387 Some(pos) => (&full_path[..pos], &full_path[pos + 1..]),
388 None => (full_path, ""),
389 };
390 ash_normalize_binding(method, path, query)
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 // v2.3.2 Binding Format Tests (METHOD|PATH|CANONICAL_QUERY)
398
399 #[test]
400 fn test_normalize_binding_basic() {
401 assert_eq!(
402 ash_normalize_binding("POST", "/api/users", "").unwrap(),
403 "POST|/api/users|"
404 );
405 }
406
407 #[test]
408 fn test_normalize_binding_lowercase_method() {
409 assert_eq!(
410 ash_normalize_binding("post", "/api/users", "").unwrap(),
411 "POST|/api/users|"
412 );
413 }
414
415 #[test]
416 fn test_normalize_binding_duplicate_slashes() {
417 assert_eq!(
418 ash_normalize_binding("GET", "/api//users///profile", "").unwrap(),
419 "GET|/api/users/profile|"
420 );
421 }
422
423 #[test]
424 fn test_normalize_binding_trailing_slash() {
425 assert_eq!(
426 ash_normalize_binding("PUT", "/api/users/", "").unwrap(),
427 "PUT|/api/users|"
428 );
429 }
430
431 #[test]
432 fn test_normalize_binding_root() {
433 assert_eq!(ash_normalize_binding("GET", "/", "").unwrap(), "GET|/|");
434 }
435
436 #[test]
437 fn test_normalize_binding_with_query() {
438 assert_eq!(
439 ash_normalize_binding("GET", "/api/users", "page=1&sort=name").unwrap(),
440 "GET|/api/users|page=1&sort=name"
441 );
442 }
443
444 #[test]
445 fn test_normalize_binding_query_sorted() {
446 assert_eq!(
447 ash_normalize_binding("GET", "/api/users", "z=3&a=1&b=2").unwrap(),
448 "GET|/api/users|a=1&b=2&z=3"
449 );
450 }
451
452 #[test]
453 fn test_normalize_binding_from_url_basic() {
454 assert_eq!(
455 ash_normalize_binding_from_url("GET", "/api/users?page=1&sort=name").unwrap(),
456 "GET|/api/users|page=1&sort=name"
457 );
458 }
459
460 #[test]
461 fn test_normalize_binding_from_url_no_query() {
462 assert_eq!(
463 ash_normalize_binding_from_url("POST", "/api/users").unwrap(),
464 "POST|/api/users|"
465 );
466 }
467
468 #[test]
469 fn test_normalize_binding_from_url_query_sorted() {
470 assert_eq!(
471 ash_normalize_binding_from_url("GET", "/api/search?z=last&a=first").unwrap(),
472 "GET|/api/search|a=first&z=last"
473 );
474 }
475
476 #[test]
477 fn test_normalize_binding_empty_method() {
478 assert!(ash_normalize_binding("", "/api", "").is_err());
479 }
480
481 #[test]
482 fn test_normalize_binding_no_leading_slash() {
483 assert!(ash_normalize_binding("GET", "api/users", "").is_err());
484 }
485
486 // Version Constants Tests
487
488 #[test]
489 fn test_version_constants() {
490 use crate::{ASH_SDK_VERSION, ASH_VERSION_PREFIX};
491
492 assert_eq!(ASH_SDK_VERSION, "2.3.5");
493 assert_eq!(ASH_VERSION_PREFIX, "ASHv2.1");
494 }
495
496 // v2.3.1 Query Canonicalization in Binding Tests
497
498 #[test]
499 fn test_normalize_binding_strips_fragment() {
500 // Fragment should be stripped from query string
501 assert_eq!(
502 ash_normalize_binding("GET", "/api/search", "q=test#section").unwrap(),
503 "GET|/api/search|q=test"
504 );
505 }
506
507 #[test]
508 fn test_normalize_binding_plus_literal() {
509 // + is literal plus in query strings, not space
510 assert_eq!(
511 ash_normalize_binding("GET", "/api/search", "q=a+b").unwrap(),
512 "GET|/api/search|q=a%2Bb"
513 );
514 }
515
516 // BUG-025: Path percent-encoding normalization tests
517
518 #[test]
519 fn test_normalize_binding_encoded_slashes() {
520 // BUG-025: Encoded slashes should be decoded and collapsed
521 assert_eq!(
522 ash_normalize_binding("GET", "/api/%2F%2F/users", "").unwrap(),
523 "GET|/api/users|"
524 );
525 }
526
527 #[test]
528 fn test_normalize_binding_encoded_double_slash() {
529 // Encoded double slash should be collapsed to single slash
530 assert_eq!(
531 ash_normalize_binding("GET", "/api%2F%2Fusers", "").unwrap(),
532 "GET|/api/users|"
533 );
534 }
535
536 #[test]
537 fn test_normalize_binding_unicode_path() {
538 // Unicode characters should be preserved (encoded in output)
539 let result = ash_normalize_binding("GET", "/api/café", "").unwrap();
540 assert!(result.starts_with("GET|/api/caf"));
541 // The é should be percent-encoded
542 assert!(result.contains("%C3%A9") || result.contains("é"));
543 }
544
545 #[test]
546 fn test_normalize_binding_mixed_encoding() {
547 // Mix of encoded and unencoded should normalize consistently
548 let result1 = ash_normalize_binding("GET", "/api/%2Ftest", "").unwrap();
549 let result2 = ash_normalize_binding("GET", "/api//test", "").unwrap();
550 // Both should collapse to /api/test
551 assert_eq!(result1, result2);
552 }
553
554 #[test]
555 fn test_normalize_binding_encoded_trailing_slash() {
556 // Encoded trailing slash should be removed
557 assert_eq!(
558 ash_normalize_binding("GET", "/api/users%2F", "").unwrap(),
559 "GET|/api/users|"
560 );
561 }
562
563 #[test]
564 fn test_normalize_binding_special_chars_preserved() {
565 // Special characters that are valid in paths should be preserved
566 let result = ash_normalize_binding("GET", "/api/users/@me", "").unwrap();
567 assert_eq!(result, "GET|/api/users/@me|");
568 }
569
570 // BUG-027: Encoded query delimiter tests
571
572 #[test]
573 fn test_normalize_binding_rejects_encoded_question_mark() {
574 // BUG-027: Encoded %3F (?) should be rejected after decoding
575 let result = ash_normalize_binding("GET", "/api/users%3Fid=5", "");
576 assert!(result.is_err());
577 assert!(result.unwrap_err().message().contains("?"));
578 }
579
580 #[test]
581 fn test_normalize_binding_rejects_doubly_encoded_question_mark() {
582 // BUG-027: Doubly encoded %253F decodes to %3F, then to ? - should be rejected
583 // Note: %253F -> %3F after first decode, but we only do one decode pass,
584 // so %253F -> %3F (stays as-is), which doesn't contain literal ?
585 // This is acceptable as it's an unusual edge case
586 let result = ash_normalize_binding("GET", "/api/users%253F", "");
587 // This should succeed because %253F decodes to "%3F" (literal chars), not "?"
588 assert!(result.is_ok());
589 }
590
591 #[test]
592 fn test_normalize_binding_allows_other_encoded_chars() {
593 // Other encoded characters should be allowed
594 // %20 = space, %2B = +
595 let result = ash_normalize_binding("GET", "/api/hello%20world", "").unwrap();
596 assert!(result.contains("/api/hello%20world"));
597 }
598
599 // BUG-035: Path segment normalization tests
600
601 #[test]
602 fn test_normalize_binding_dot_segment() {
603 // BUG-035: Single dot should be removed
604 assert_eq!(
605 ash_normalize_binding("GET", "/api/./users", "").unwrap(),
606 "GET|/api/users|"
607 );
608 }
609
610 #[test]
611 fn test_normalize_binding_double_dot_segment() {
612 // BUG-035: Double dot should go up one level
613 assert_eq!(
614 ash_normalize_binding("GET", "/api/v1/../users", "").unwrap(),
615 "GET|/api/users|"
616 );
617 }
618
619 #[test]
620 fn test_normalize_binding_multiple_dots() {
621 // BUG-035: Multiple dot segments
622 assert_eq!(
623 ash_normalize_binding("GET", "/api/v1/./users/../admin", "").unwrap(),
624 "GET|/api/v1/admin|"
625 );
626 }
627
628 #[test]
629 fn test_normalize_binding_dots_at_root() {
630 // BUG-035: Can't go above root
631 assert_eq!(
632 ash_normalize_binding("GET", "/../api", "").unwrap(),
633 "GET|/api|"
634 );
635 }
636
637 #[test]
638 fn test_normalize_binding_only_dots() {
639 // BUG-035: Path with only dots should become root
640 assert_eq!(
641 ash_normalize_binding("GET", "/./.", "").unwrap(),
642 "GET|/|"
643 );
644 }
645
646 // BUG-042: ASCII method validation tests
647
648 #[test]
649 fn test_normalize_binding_rejects_unicode_method() {
650 // BUG-042: Non-ASCII method should be rejected
651 let result = ash_normalize_binding("GËṪ", "/api", "");
652 assert!(result.is_err());
653 assert!(result.unwrap_err().message().contains("ASCII"));
654 }
655
656 #[test]
657 fn test_normalize_binding_ascii_method_uppercased() {
658 // BUG-042: ASCII method should be uppercased consistently
659 assert_eq!(
660 ash_normalize_binding("get", "/api", "").unwrap(),
661 "GET|/api|"
662 );
663 assert_eq!(
664 ash_normalize_binding("Post", "/api", "").unwrap(),
665 "POST|/api|"
666 );
667 }
668
669 // BUG-043: Whitespace query string tests
670
671 #[test]
672 fn test_normalize_binding_whitespace_only_query() {
673 // BUG-043: Whitespace-only query should be treated as empty
674 assert_eq!(
675 ash_normalize_binding("GET", "/api", " ").unwrap(),
676 "GET|/api|"
677 );
678 assert_eq!(
679 ash_normalize_binding("GET", "/api", "\t\n").unwrap(),
680 "GET|/api|"
681 );
682 }
683
684 #[test]
685 fn test_normalize_binding_query_with_leading_trailing_whitespace() {
686 // BUG-043: Query should be trimmed before processing
687 assert_eq!(
688 ash_normalize_binding("GET", "/api", " a=1 ").unwrap(),
689 "GET|/api|a=1"
690 );
691 }
692}