axum_conf/utils.rs
1//!
2//! Utility types and functions for common patterns in the service.
3//!
4//! This module provides:
5//! - [`Sensitive`] - A wrapper type for sensitive data that hides values in debug output
6//! - [`RequestIdGenerator`] - Generates or preserves request IDs for distributed tracing
7//! - [`replace_handlebars_with_env`] - Template substitution for environment variables
8//! - [`ApiVersion`] - API version extraction and management for versioned APIs
9//!
10
11use {
12 http::{HeaderValue, Request},
13 regex::{Captures, Regex},
14 serde::Deserialize,
15 std::{env, sync::LazyLock},
16 tower_http::request_id::{MakeRequestId, RequestId},
17 uuid::{ContextV7, Timestamp, Uuid},
18 zeroize::{Zeroize, ZeroizeOnDrop},
19};
20
21/// Regular expression pattern for matching handlebars-style environment variable references.
22/// Matches patterns like `{{ VAR_NAME }}` with optional whitespace around the variable name.
23/// Variable names must be uppercase letters, digits, or underscores (standard env var naming).
24static HANDLEBAR_REGEXP: LazyLock<Regex> =
25 LazyLock::new(|| Regex::new(r"\{\{\s*([A-Z0-9_]+)\s*\}\}").unwrap());
26
27/// A wrapper type for sensitive data that obscures the value in debug output
28/// and securely zeros memory when dropped.
29///
30/// This type is useful for wrapping secrets, passwords, API keys, and other
31/// sensitive information that should not be accidentally exposed in logs,
32/// error messages, or debug output.
33///
34/// The inner value remains accessible through the public field `0`, but when
35/// formatted using `Debug`, it displays as `Sensitive(****)` instead of the
36/// actual value.
37///
38/// # Type Parameters
39///
40/// - `T`: The type of the sensitive value, which must implement `Default`
41///
42/// # Examples
43///
44/// ```
45/// use axum_conf::Sensitive;
46///
47/// let api_key = Sensitive::from("secret-key-12345");
48/// println!("{:?}", api_key); // Prints: Sensitive(****)
49///
50/// // Access the actual value when needed
51/// let key_value: &str = &api_key.0;
52/// ```
53///
54/// # Security Features
55///
56/// - **Debug hiding**: Debug output shows `Sensitive(****)` instead of the value
57/// - **Memory zeroing**: When `Sensitive<String>` is dropped, the memory is securely
58/// overwritten with zeros to prevent secrets from lingering in memory
59///
60/// # Security Limitations
61///
62/// This type does NOT:
63/// - Prevent the value from being read if you have access to the `Sensitive` instance
64/// - Encrypt or secure the value in memory while in use
65/// - Prevent the value from being serialized if using `Serialize`
66/// - Prevent the compiler from copying the value (use with care in generic contexts)
67///
68/// For true security, combine with other security measures like secure memory handling.
69///
70/// # Derive Macros
71///
72/// Uses `ZeroizeOnDrop` from the `zeroize` crate to automatically zero memory when dropped.
73#[derive(Clone, Deserialize, Default, Zeroize, ZeroizeOnDrop)]
74pub struct Sensitive<T: Default + Zeroize>(pub T);
75
76impl Sensitive<String> {
77 /// Creates a new `Sensitive<String>` from a string slice.
78 ///
79 /// # Examples
80 ///
81 /// ```
82 /// use axum_conf::Sensitive;
83 ///
84 /// let password = Sensitive::from("my-secret-password");
85 /// ```
86 pub fn from(s: &str) -> Self {
87 Self(s.to_string())
88 }
89}
90
91impl<T: Default + Zeroize + PartialEq> PartialEq for Sensitive<T> {
92 fn eq(&self, other: &Self) -> bool {
93 self.0 == other.0
94 }
95}
96
97impl<T: Default + Zeroize> std::fmt::Debug for Sensitive<T> {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 write!(f, "Sensitive(****)")
100 }
101}
102
103/// Request ID generator for distributed tracing and request correlation.
104///
105/// This generator implements the `MakeRequestId` trait from `tower-http` to either:
106/// 1. Preserve an existing `x-request-id` header from the incoming request, or
107/// 2. Generate a new UUIDv7 if no request ID is present
108///
109/// Using UUIDv7 provides several benefits:
110/// - Time-ordered: IDs are sortable by creation time
111/// - Unique: Collision-resistant across distributed systems
112/// - Traceable: Can correlate requests across multiple services
113///
114/// # Request ID Flow
115///
116/// ```text
117/// Client Request
118/// │
119/// ├─ Has x-request-id header? ─> Preserve it
120/// │
121/// └─ No header? ─> Generate new UUIDv7
122/// ```
123///
124/// # Examples
125///
126/// ```
127/// use axum_conf::RequestIdGenerator;
128/// use tower_http::request_id::SetRequestIdLayer;
129///
130/// // Add to your Axum router
131/// let layer = SetRequestIdLayer::x_request_id(RequestIdGenerator);
132/// ```
133///
134/// # Use Cases
135///
136/// - **Distributed Tracing**: Track a request across multiple microservices
137/// - **Debugging**: Correlate logs from different components of a request
138/// - **Auditing**: Track the lifecycle of a request for compliance
139/// - **Monitoring**: Measure end-to-end request latency
140#[derive(Debug, Clone, Copy)]
141pub struct RequestIdGenerator;
142
143impl MakeRequestId for RequestIdGenerator {
144 /// Generates or extracts a request ID from an HTTP request.
145 ///
146 /// If the request already has an `x-request-id` header, that value is preserved.
147 /// Otherwise, a new UUIDv7 is generated with high-precision timestamp context.
148 ///
149 /// # Arguments
150 ///
151 /// * `req` - The HTTP request to process
152 ///
153 /// # Returns
154 ///
155 /// An `Option<RequestId>` containing either the existing or newly generated ID.
156 /// Returns `None` only if UUID generation or header value creation fails
157 /// (which is extremely rare in practice).
158 fn make_request_id<B>(&mut self, req: &Request<B>) -> Option<RequestId> {
159 match req.headers().get("x-request-id") {
160 Some(value) => Some(RequestId::new(value.clone())),
161 None => {
162 let cx = ContextV7::new().with_additional_precision();
163 let uuid = Uuid::new_v7(Timestamp::now(cx));
164 let value = HeaderValue::from_str(&uuid.to_string()).ok()?;
165 Some(RequestId::new(value))
166 }
167 }
168 }
169}
170
171/// Replaces handlebars-style placeholders with environment variable values.
172///
173/// Searches through the input string for patterns like `{{ VAR_NAME }}` and replaces
174/// them with the corresponding environment variable value. Variable names are
175/// case-sensitive and must consist of uppercase letters, digits, or underscores.
176///
177/// Whitespace around the variable name is allowed: `{{VAR}}`, `{{ VAR }}`, and
178/// `{{ VAR }}` are all valid and equivalent.
179///
180/// # Arguments
181///
182/// * `input` - A string slice containing the template text with placeholders
183///
184/// # Returns
185///
186/// A new `String` with all placeholders replaced by their environment variable values.
187/// If an environment variable is not set, it is replaced with an empty string.
188///
189/// # Examples
190///
191/// ```
192/// use axum_conf::replace_handlebars_with_env;
193///
194/// // Assume HOME environment variable exists (standard on Unix systems)
195/// let template = "Path: {{ HOME }}/config";
196/// let result = replace_handlebars_with_env(template);
197/// // Result will be something like "Path: /home/user/config"
198/// assert!(result.starts_with("Path: "));
199///
200/// // Missing variables become empty strings
201/// let template = "Value: {{ MISSING_VAR }}";
202/// let result = replace_handlebars_with_env(template);
203/// assert_eq!(result, "Value: ");
204/// ```
205///
206/// # Use Cases
207///
208/// This function is primarily used for:
209/// - **Configuration files**: Keep sensitive values out of TOML files
210/// - **Connection strings**: Inject credentials from environment
211/// - **Dynamic configuration**: Support different values per environment
212///
213/// # Pattern Details
214///
215/// The function uses a regular expression that matches:
216/// - Opening braces: `{{`
217/// - Optional whitespace: `\s*`
218/// - Variable name: `[A-Z0-9_]+` (uppercase alphanumeric and underscores)
219/// - Optional whitespace: `\s*`
220/// - Closing braces: `}}`
221///
222/// # Security Considerations
223///
224/// - Environment variables are NOT encrypted in memory
225/// - Substituted values appear in the returned string in plain text
226/// - Consider using [`Sensitive`] wrapper for secrets after substitution
227/// - Be cautious when logging or displaying the result
228pub fn replace_handlebars_with_env(input: &str) -> String {
229 HANDLEBAR_REGEXP
230 .replace_all(input, |caps: &Captures| {
231 let var_name = &caps[1];
232 env::var(var_name).unwrap_or_else(|_| {
233 tracing::warn!(
234 variable = %var_name,
235 "Environment variable not found, substituting with empty string"
236 );
237 String::new()
238 })
239 })
240 .to_string()
241}
242
243/// API version extracted from request headers or path.
244///
245/// This type is used to track which version of the API a request is targeting.
246/// It can be inserted into request extensions by versioning middleware and
247/// extracted in handlers for version-specific logic.
248///
249/// # Examples
250///
251/// ```
252/// use axum_conf::ApiVersion;
253///
254/// let version = ApiVersion::new(2);
255/// assert_eq!(version.as_u32(), 2);
256/// assert_eq!(version.to_string(), "v2");
257/// ```
258#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
259pub struct ApiVersion(u32);
260
261impl ApiVersion {
262 /// Creates a new API version
263 pub fn new(version: u32) -> Self {
264 Self(version)
265 }
266
267 /// Returns the version number as u32
268 #[allow(unused)]
269 pub fn as_u32(&self) -> u32 {
270 self.0
271 }
272
273 /// Extracts API version from request path.
274 ///
275 /// Looks for patterns like `/v1/`, `/v2/`, `/api/v1/`, etc.
276 pub fn from_path(path: &str) -> Option<Self> {
277 static VERSION_PATH_REGEX: LazyLock<Regex> =
278 LazyLock::new(|| Regex::new(r"/v(\d+)(?:/|$)").unwrap());
279
280 VERSION_PATH_REGEX
281 .captures(path)
282 .and_then(|caps| caps.get(1))
283 .and_then(|m| m.as_str().parse::<u32>().ok())
284 .map(ApiVersion::new)
285 }
286
287 /// Extracts API version from request header.
288 ///
289 /// Supports headers like:
290 /// - `X-API-Version: 2`
291 /// - `Accept: application/vnd.api+json;version=2`
292 pub fn from_header(header_value: &str) -> Option<Self> {
293 // Try direct version number first (X-API-Version: 2)
294 if let Ok(version) = header_value.trim().parse::<u32>() {
295 return Some(ApiVersion::new(version));
296 }
297
298 // Try Accept header format (version=2)
299 static VERSION_HEADER_REGEX: LazyLock<Regex> =
300 LazyLock::new(|| Regex::new(r"version=(\d+)").unwrap());
301
302 VERSION_HEADER_REGEX
303 .captures(header_value)
304 .and_then(|caps| caps.get(1))
305 .and_then(|m| m.as_str().parse::<u32>().ok())
306 .map(ApiVersion::new)
307 }
308
309 /// Extracts API version from query parameter.
310 ///
311 /// Looks for `?version=2` or `&version=2` in the query string.
312 pub fn from_query(query: &str) -> Option<Self> {
313 static VERSION_QUERY_REGEX: LazyLock<Regex> =
314 LazyLock::new(|| Regex::new(r"[?&]version=(\d+)").unwrap());
315
316 VERSION_QUERY_REGEX
317 .captures(query)
318 .and_then(|caps| caps.get(1))
319 .and_then(|m| m.as_str().parse::<u32>().ok())
320 .map(ApiVersion::new)
321 }
322}
323
324impl std::fmt::Display for ApiVersion {
325 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326 write!(f, "v{}", self.0)
327 }
328}
329
330impl From<u32> for ApiVersion {
331 fn from(version: u32) -> Self {
332 ApiVersion::new(version)
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use proptest::prelude::*;
340
341 // ========================================================================
342 // Property-based tests for replace_handlebars_with_env
343 // ========================================================================
344
345 proptest! {
346 /// Strings without handlebars patterns should pass through unchanged
347 #[test]
348 fn handlebars_no_pattern_unchanged(s in "[^{}]*") {
349 // Input without any braces should be unchanged
350 let result = replace_handlebars_with_env(&s);
351 prop_assert_eq!(result, s);
352 }
353
354 /// The function should never panic on arbitrary input
355 #[test]
356 fn handlebars_never_panics(s in ".*") {
357 // Just verify it doesn't panic - we don't care about the result
358 let _ = replace_handlebars_with_env(&s);
359 }
360
361 /// Single braces should pass through unchanged
362 #[test]
363 fn handlebars_single_braces_unchanged(
364 prefix in "[^{}]*",
365 middle in "[^{}]*",
366 suffix in "[^{}]*"
367 ) {
368 let input = format!("{prefix}{{{middle}}}{suffix}");
369 let result = replace_handlebars_with_env(&input);
370 // Single braces aren't our pattern, should be unchanged
371 prop_assert_eq!(result, input);
372 }
373
374 /// Valid patterns with set env vars should be substituted
375 #[test]
376 fn handlebars_valid_pattern_substituted(
377 var_name in "[A-Z][A-Z0-9_]{0,10}",
378 var_value in "[a-zA-Z0-9_]{1,20}",
379 prefix in "[^{}]{0,10}",
380 suffix in "[^{}]{0,10}"
381 ) {
382 // Set up test env var with unique name to avoid conflicts
383 let test_var = format!("PROPTEST_{var_name}");
384 unsafe { std::env::set_var(&test_var, &var_value); }
385
386 let input = format!("{prefix}{{{{ {test_var} }}}}{suffix}");
387 let result = replace_handlebars_with_env(&input);
388 let expected = format!("{prefix}{var_value}{suffix}");
389
390 unsafe { std::env::remove_var(&test_var); }
391
392 prop_assert_eq!(result, expected);
393 }
394
395 /// Multiple patterns in one string should all be substituted
396 #[test]
397 fn handlebars_multiple_patterns(
398 var1 in "[A-Z][A-Z0-9_]{0,5}",
399 var2 in "[A-Z][A-Z0-9_]{0,5}",
400 val1 in "[a-z]{1,10}",
401 val2 in "[a-z]{1,10}"
402 ) {
403 let test_var1 = format!("PROPTEST_MULTI1_{var1}");
404 let test_var2 = format!("PROPTEST_MULTI2_{var2}");
405
406 unsafe {
407 std::env::set_var(&test_var1, &val1);
408 std::env::set_var(&test_var2, &val2);
409 }
410
411 let input = format!("a={{{{ {test_var1} }}}} b={{{{ {test_var2} }}}}");
412 let result = replace_handlebars_with_env(&input);
413 let expected = format!("a={val1} b={val2}");
414
415 unsafe {
416 std::env::remove_var(&test_var1);
417 std::env::remove_var(&test_var2);
418 }
419
420 prop_assert_eq!(result, expected);
421 }
422
423 /// Missing env vars should become empty strings
424 #[test]
425 fn handlebars_missing_var_empty(
426 var_name in "[A-Z][A-Z0-9_]{5,15}" // Use longer names to avoid collisions
427 ) {
428 let test_var = format!("PROPTEST_MISSING_{var_name}");
429 // Ensure it's not set
430 unsafe { std::env::remove_var(&test_var); }
431
432 let input = format!("value={{{{ {test_var} }}}}");
433 let result = replace_handlebars_with_env(&input);
434
435 prop_assert_eq!(result, "value=");
436 }
437 }
438
439 // ========================================================================
440 // Property-based tests for ApiVersion
441 // ========================================================================
442
443 proptest! {
444 /// ApiVersion round-trips through u32
445 #[test]
446 fn api_version_roundtrip(version in 0u32..1000) {
447 let api_version = ApiVersion::new(version);
448 prop_assert_eq!(api_version.as_u32(), version);
449 }
450
451 /// ApiVersion from_path extracts version correctly
452 #[test]
453 fn api_version_from_path(version in 1u32..100) {
454 let path = format!("/v{version}/resource");
455 let result = ApiVersion::from_path(&path);
456 prop_assert_eq!(result, Some(ApiVersion::new(version)));
457 }
458
459 /// ApiVersion from_header with direct number
460 #[test]
461 fn api_version_from_header_direct(version in 1u32..100) {
462 let header = format!("{version}");
463 let result = ApiVersion::from_header(&header);
464 prop_assert_eq!(result, Some(ApiVersion::new(version)));
465 }
466
467 /// ApiVersion from_header with version= format
468 #[test]
469 fn api_version_from_header_param(version in 1u32..100) {
470 let header = format!("application/json; version={version}");
471 let result = ApiVersion::from_header(&header);
472 prop_assert_eq!(result, Some(ApiVersion::new(version)));
473 }
474
475 /// ApiVersion from_query extracts version correctly
476 #[test]
477 fn api_version_from_query(version in 1u32..100) {
478 let query = format!("?foo=bar&version={version}&baz=qux");
479 let result = ApiVersion::from_query(&query);
480 prop_assert_eq!(result, Some(ApiVersion::new(version)));
481 }
482
483 /// ApiVersion Display format is correct
484 #[test]
485 fn api_version_display(version in 0u32..1000) {
486 let api_version = ApiVersion::new(version);
487 let display = api_version.to_string();
488 prop_assert_eq!(display, format!("v{version}"));
489 }
490 }
491
492 // ========================================================================
493 // Property-based tests for Sensitive wrapper
494 // ========================================================================
495
496 proptest! {
497 /// Sensitive wrapper preserves the inner value
498 #[test]
499 fn sensitive_preserves_value(s in ".*") {
500 let sensitive = Sensitive::from(s.as_str());
501 prop_assert_eq!(&sensitive.0, &s);
502 }
503
504 /// Sensitive Debug output never contains the actual value
505 #[test]
506 fn sensitive_debug_hides_value(s in "[a-zA-Z0-9]{1,50}") {
507 let sensitive = Sensitive::from(s.as_str());
508 let debug_output = format!("{:?}", sensitive);
509
510 // Debug output should contain "****" and NOT the actual value
511 prop_assert!(debug_output.contains("****"));
512 // Only check non-trivial strings to avoid false positives
513 if s.len() > 4 {
514 prop_assert!(!debug_output.contains(&s));
515 }
516 }
517 }
518
519 // ========================================================================
520 // Memory zeroing tests for Sensitive
521 // ========================================================================
522
523 #[test]
524 fn sensitive_drop_zeros_memory() {
525 // We can't directly inspect memory after drop in safe Rust,
526 // but we can verify the Drop implementation runs without panicking
527 // and that the inner value is accessible before drop.
528 let secret = "super-secret-password-12345";
529 let sensitive = Sensitive::from(secret);
530
531 // Value is accessible before drop
532 assert_eq!(sensitive.0, secret);
533
534 // Drop runs without panic (zeroize is called)
535 drop(sensitive);
536
537 // If we got here, Drop ran successfully
538 }
539
540 #[test]
541 fn sensitive_clone_creates_independent_copy() {
542 let original = Sensitive::from("original-secret");
543 let cloned = original.clone();
544
545 // Both have the same value
546 assert_eq!(original.0, cloned.0);
547
548 // Dropping one doesn't affect the other
549 drop(original);
550 assert_eq!(cloned.0, "original-secret");
551 }
552
553 #[test]
554 fn sensitive_zeroize_trait_is_used() {
555 use zeroize::Zeroize;
556
557 // Verify that String implements Zeroize (which is required by our Sensitive)
558 let mut s = String::from("secret");
559 s.zeroize();
560 assert!(s.is_empty(), "Zeroize should clear the string");
561 }
562}