Skip to main content

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}