Skip to main content

ash_core/
enriched.rs

1//! Enriched API variants (Phase 2).
2//!
3//! These functions return structured result types with metadata,
4//! extending the existing Core primitives without changing them.
5//!
6//! ## Design Principle
7//!
8//! Each enriched function wraps the corresponding Core function:
9//! - Same behavior, same validation, same errors
10//! - Additional metadata in the return type
11//! - No new logic — only metadata extraction
12//!
13//! ## Functions
14//!
15//! | Enriched | Base Function |
16//! |----------|---------------|
17//! | `ash_canonicalize_query_enriched` | `ash_canonicalize_query` |
18//! | `ash_hash_body_enriched` | `ash_hash_body` |
19//! | `ash_normalize_binding_enriched` | `ash_normalize_binding` |
20
21use crate::canonicalize::ash_canonicalize_query;
22use crate::errors::{AshError, AshErrorCode};
23use crate::proof::ash_hash_body;
24
25// ── Enriched Query Canonicalization ──────────────────────────────────
26
27/// Result of enriched query canonicalization.
28///
29/// Contains the canonical query string plus metadata about the input.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct CanonicalQueryResult {
32    /// The canonical query string (same as `ash_canonicalize_query` output)
33    pub canonical: String,
34
35    /// Number of key=value pairs in the canonical output
36    pub pairs_count: usize,
37
38    /// Whether the input had a fragment (`#...`) that was stripped
39    pub had_fragment: bool,
40
41    /// Whether the input had a leading `?` that was stripped
42    pub had_leading_question_mark: bool,
43
44    /// Number of distinct keys after normalization
45    pub unique_keys: usize,
46}
47
48/// Canonicalize a query string and return enriched metadata.
49///
50/// Wraps `ash_canonicalize_query` — identical behavior, richer return type.
51///
52/// # Example
53///
54/// ```rust
55/// use ash_core::enriched::ash_canonicalize_query_enriched;
56///
57/// let result = ash_canonicalize_query_enriched("?z=3&a=1&a=2#section").unwrap();
58/// assert_eq!(result.canonical, "a=1&a=2&z=3");
59/// assert_eq!(result.pairs_count, 3);
60/// assert!(result.had_fragment);
61/// assert!(result.had_leading_question_mark);
62/// assert_eq!(result.unique_keys, 2); // "a" and "z"
63/// ```
64pub fn ash_canonicalize_query_enriched(input: &str) -> Result<CanonicalQueryResult, AshError> {
65    let had_leading_question_mark = input.starts_with('?');
66    let had_fragment = input.contains('#');
67
68    let canonical = ash_canonicalize_query(input)?;
69
70    let pairs_count = if canonical.is_empty() {
71        0
72    } else {
73        canonical.split('&').count()
74    };
75
76    let unique_keys = if canonical.is_empty() {
77        0
78    } else {
79        let mut keys: Vec<&str> = canonical
80            .split('&')
81            .filter_map(|pair| pair.split('=').next())
82            .collect();
83        keys.sort();
84        keys.dedup();
85        keys.len()
86    };
87
88    Ok(CanonicalQueryResult {
89        canonical,
90        pairs_count,
91        had_fragment,
92        had_leading_question_mark,
93        unique_keys,
94    })
95}
96
97// ── Enriched Body Hashing ────────────────────────────────────────────
98
99/// Result of enriched body hashing.
100///
101/// Contains the SHA-256 hash plus metadata about the input.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct BodyHashResult {
104    /// The SHA-256 hex hash (same as `ash_hash_body` output, 64 chars)
105    pub hash: String,
106
107    /// Size of the canonical body input in bytes
108    pub input_bytes: usize,
109
110    /// Whether the input was empty
111    pub is_empty: bool,
112}
113
114/// Hash a canonical body and return enriched metadata.
115///
116/// Wraps `ash_hash_body` — identical behavior, richer return type.
117///
118/// # Example
119///
120/// ```rust
121/// use ash_core::enriched::ash_hash_body_enriched;
122///
123/// let result = ash_hash_body_enriched(r#"{"amount":100}"#);
124/// assert_eq!(result.hash.len(), 64);
125/// assert_eq!(result.input_bytes, 14);
126/// assert!(!result.is_empty);
127///
128/// let empty = ash_hash_body_enriched("");
129/// assert!(empty.is_empty);
130/// assert_eq!(empty.input_bytes, 0);
131/// ```
132pub fn ash_hash_body_enriched(canonical_body: &str) -> BodyHashResult {
133    let hash = ash_hash_body(canonical_body);
134    let input_bytes = canonical_body.len();
135
136    BodyHashResult {
137        hash,
138        input_bytes,
139        is_empty: canonical_body.is_empty(),
140    }
141}
142
143// ── Enriched Binding Normalization ───────────────────────────────────
144
145/// Structured binding with accessible parts.
146///
147/// Contains the normalized binding string plus its decomposed components.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct NormalizedBinding {
150    /// The full binding string (METHOD|PATH|CANONICAL_QUERY)
151    pub binding: String,
152
153    /// HTTP method (uppercased)
154    pub method: String,
155
156    /// Normalized path (decoded, dot-resolved, re-encoded)
157    pub path: String,
158
159    /// Canonical query string (sorted, normalized, may be empty)
160    pub canonical_query: String,
161
162    /// Whether the input query was non-empty
163    pub had_query: bool,
164}
165
166/// Normalize a binding and return enriched structured result.
167///
168/// Wraps `ash_normalize_binding` — identical behavior, richer return type.
169///
170/// # Example
171///
172/// ```rust
173/// use ash_core::enriched::ash_normalize_binding_enriched;
174///
175/// let result = ash_normalize_binding_enriched("post", "/api//users/", "z=3&a=1").unwrap();
176/// assert_eq!(result.binding, "POST|/api/users|a=1&z=3");
177/// assert_eq!(result.method, "POST");
178/// assert_eq!(result.path, "/api/users");
179/// assert_eq!(result.canonical_query, "a=1&z=3");
180/// assert!(result.had_query);
181/// ```
182pub fn ash_normalize_binding_enriched(
183    method: &str,
184    path: &str,
185    query: &str,
186) -> Result<NormalizedBinding, AshError> {
187    let binding = crate::ash_normalize_binding(method, path, query)?;
188
189    // Parse the binding back into parts (METHOD|PATH|QUERY)
190    // Clone parts before consuming binding
191    let parsed = ash_parse_binding(&binding)?;
192    Ok(NormalizedBinding {
193        had_query: !query.trim().is_empty(),
194        ..parsed
195    })
196}
197
198/// Parse an existing normalized binding string into structured parts.
199///
200/// Useful when you have a binding from `build_request_proof` or `verify_incoming_request`
201/// and want to inspect its components.
202///
203/// # Example
204///
205/// ```rust
206/// use ash_core::enriched::ash_parse_binding;
207///
208/// let parts = ash_parse_binding("POST|/api/users|page=1&sort=name").unwrap();
209/// assert_eq!(parts.method, "POST");
210/// assert_eq!(parts.path, "/api/users");
211/// assert_eq!(parts.canonical_query, "page=1&sort=name");
212/// ```
213pub fn ash_parse_binding(binding: &str) -> Result<NormalizedBinding, AshError> {
214    let parts: Vec<&str> = binding.splitn(3, '|').collect();
215    if parts.len() != 3 {
216        return Err(AshError::new(
217            AshErrorCode::ValidationError,
218            format!(
219                "Invalid binding format: expected METHOD|PATH|QUERY, got {} parts",
220                parts.len()
221            ),
222        ));
223    }
224
225    let canonical_query = parts[2].to_string();
226    let had_query = !canonical_query.is_empty();
227
228    Ok(NormalizedBinding {
229        binding: binding.to_string(),
230        method: parts[0].to_string(),
231        path: parts[1].to_string(),
232        canonical_query,
233        had_query,
234    })
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    // ── Query Enrichment Tests ────────────────────────────────────────
242
243    #[test]
244    fn test_query_enriched_basic() {
245        let result = ash_canonicalize_query_enriched("a=1&b=2").unwrap();
246        assert_eq!(result.canonical, "a=1&b=2");
247        assert_eq!(result.pairs_count, 2);
248        assert_eq!(result.unique_keys, 2);
249        assert!(!result.had_fragment);
250        assert!(!result.had_leading_question_mark);
251    }
252
253    #[test]
254    fn test_query_enriched_with_fragment() {
255        let result = ash_canonicalize_query_enriched("a=1#section").unwrap();
256        assert_eq!(result.canonical, "a=1");
257        assert!(result.had_fragment);
258    }
259
260    #[test]
261    fn test_query_enriched_with_question_mark() {
262        let result = ash_canonicalize_query_enriched("?a=1").unwrap();
263        assert_eq!(result.canonical, "a=1");
264        assert!(result.had_leading_question_mark);
265    }
266
267    #[test]
268    fn test_query_enriched_duplicate_keys() {
269        let result = ash_canonicalize_query_enriched("a=1&a=2&b=3").unwrap();
270        assert_eq!(result.pairs_count, 3);
271        assert_eq!(result.unique_keys, 2); // "a" and "b"
272    }
273
274    #[test]
275    fn test_query_enriched_empty() {
276        let result = ash_canonicalize_query_enriched("").unwrap();
277        assert_eq!(result.canonical, "");
278        assert_eq!(result.pairs_count, 0);
279        assert_eq!(result.unique_keys, 0);
280    }
281
282    #[test]
283    fn test_query_enriched_sorting() {
284        let result = ash_canonicalize_query_enriched("z=3&a=1&m=2").unwrap();
285        assert_eq!(result.canonical, "a=1&m=2&z=3");
286        assert_eq!(result.pairs_count, 3);
287    }
288
289    #[test]
290    fn test_query_enriched_full_metadata() {
291        let result = ash_canonicalize_query_enriched("?z=3&a=1&a=2#frag").unwrap();
292        assert_eq!(result.canonical, "a=1&a=2&z=3");
293        assert_eq!(result.pairs_count, 3);
294        assert_eq!(result.unique_keys, 2);
295        assert!(result.had_fragment);
296        assert!(result.had_leading_question_mark);
297    }
298
299    // ── Body Hash Enrichment Tests ────────────────────────────────────
300
301    #[test]
302    fn test_body_hash_enriched_basic() {
303        let result = ash_hash_body_enriched(r#"{"amount":100}"#);
304        assert_eq!(result.hash.len(), 64);
305        assert_eq!(result.input_bytes, 14);
306        assert!(!result.is_empty);
307    }
308
309    #[test]
310    fn test_body_hash_enriched_empty() {
311        let result = ash_hash_body_enriched("");
312        assert_eq!(result.hash.len(), 64);
313        assert_eq!(result.input_bytes, 0);
314        assert!(result.is_empty);
315    }
316
317    #[test]
318    fn test_body_hash_enriched_matches_base() {
319        let body = r#"{"test":"value"}"#;
320        let base = ash_hash_body(body);
321        let enriched = ash_hash_body_enriched(body);
322        assert_eq!(base, enriched.hash);
323    }
324
325    // ── Binding Enrichment Tests ──────────────────────────────────────
326
327    #[test]
328    fn test_binding_enriched_basic() {
329        let result = ash_normalize_binding_enriched("POST", "/api/users", "").unwrap();
330        assert_eq!(result.binding, "POST|/api/users|");
331        assert_eq!(result.method, "POST");
332        assert_eq!(result.path, "/api/users");
333        assert_eq!(result.canonical_query, "");
334        assert!(!result.had_query);
335    }
336
337    #[test]
338    fn test_binding_enriched_with_query() {
339        let result =
340            ash_normalize_binding_enriched("GET", "/api/search", "z=3&a=1").unwrap();
341        assert_eq!(result.binding, "GET|/api/search|a=1&z=3");
342        assert_eq!(result.method, "GET");
343        assert_eq!(result.path, "/api/search");
344        assert_eq!(result.canonical_query, "a=1&z=3");
345        assert!(result.had_query);
346    }
347
348    #[test]
349    fn test_binding_enriched_normalization() {
350        let result =
351            ash_normalize_binding_enriched("post", "/api//users/", "").unwrap();
352        assert_eq!(result.method, "POST");
353        assert_eq!(result.path, "/api/users");
354    }
355
356    #[test]
357    fn test_binding_enriched_matches_base() {
358        let base = crate::ash_normalize_binding("GET", "/api/test", "b=2&a=1").unwrap();
359        let enriched =
360            ash_normalize_binding_enriched("GET", "/api/test", "b=2&a=1").unwrap();
361        assert_eq!(base, enriched.binding);
362    }
363
364    // ── Parse Binding Tests ───────────────────────────────────────────
365
366    #[test]
367    fn test_parse_binding_basic() {
368        let result = ash_parse_binding("POST|/api/users|page=1").unwrap();
369        assert_eq!(result.method, "POST");
370        assert_eq!(result.path, "/api/users");
371        assert_eq!(result.canonical_query, "page=1");
372        assert!(result.had_query);
373    }
374
375    #[test]
376    fn test_parse_binding_no_query() {
377        let result = ash_parse_binding("GET|/|").unwrap();
378        assert_eq!(result.method, "GET");
379        assert_eq!(result.path, "/");
380        assert_eq!(result.canonical_query, "");
381        assert!(!result.had_query);
382    }
383
384    #[test]
385    fn test_parse_binding_invalid_format() {
386        assert!(ash_parse_binding("invalid").is_err());
387        assert!(ash_parse_binding("GET|/api").is_err());
388    }
389
390    #[test]
391    fn test_parse_binding_roundtrip() {
392        let binding = crate::ash_normalize_binding("PUT", "/api/resource", "id=5").unwrap();
393        let parsed = ash_parse_binding(&binding).unwrap();
394        assert_eq!(parsed.binding, binding);
395        assert_eq!(parsed.method, "PUT");
396    }
397}