palisade_errors/convenience.rs
1//! Convenience macros for creating errors with format strings.
2//!
3//! # Security Model
4//!
5//! These macros enforce compile-time safety to prevent untrusted data from
6//! leaking into error messages without explicit sanitization.
7//!
8//! # Rules
9//!
10//! 1. **Operation names MUST be string literals** - prevents dynamic injection
11//! 2. **Format strings MUST be string literals** - prevents format string attacks
12//! 3. **Format arguments must be sanitized via `sanitized!()` wrapper** - bounded length
13//!
14//! # Usage
15//!
16//! ```rust
17//! # use palisade_errors::{config_err, definitions, sanitized};
18//! let line_num = 42;
19//! // ✓ CORRECT: Format string is literal, variable is sanitized
20//! let err = config_err!(
21//! &definitions::CFG_PARSE_FAILED,
22//! "validate",
23//! "Invalid value at line {}",
24//! sanitized!(line_num)
25//! );
26//! ```
27//!
28//! ```rust,compile_fail
29//! # use palisade_errors::{config_err, definitions};
30//! let user_input = "attacker data";
31//! // ✗ COMPILE ERROR: Operation must be a literal
32//! let err = config_err!(&definitions::CFG_PARSE_FAILED, user_input, "Failed");
33//! ```
34//!
35//! ```rust,compile_fail
36//! # use palisade_errors::{config_err, definitions};
37//! let unsanitized = "oops";
38//! // ✗ COMPILE ERROR: Args must be wrapped in sanitized!()
39//! let err = config_err!(&definitions::CFG_PARSE_FAILED, "op", "{}", unsanitized);
40//! ```
41//!
42//! ## Sanitization
43//!
44//! The `sanitized!()` macro truncates strings to prevent DoS via massive error messages
45//! and ensures all format arguments are bounded in length.
46//!
47//! # Security Properties
48//!
49//! This module implements several security principles to make the error macro system dumb-proof and secure:
50//! - **Compile-time Literal Enforcement**: Requires operation names, details, and format strings to be string literals, preventing runtime injection or format string vulnerabilities.
51//! - **Mandatory Sanitization for Dynamic Data**: Dynamic arguments must be explicitly wrapped in `sanitized!()` macro. This prevents accidental inclusion of unsanitized data.
52//! - **Length Bounding and Truncation**: Sanitized values are strictly limited to 256 characters to prevent DoS through oversized error messages or logs.
53//! - **UTF-8 Boundary Respect**: Truncation always occurs at valid character boundaries to avoid creating invalid strings that could cause downstream parsing errors.
54//! - **Control Character Neutralization**: Non-printable control characters are replaced with '?' to prevent log injection, formatting disruption, or terminal escape sequence attacks.
55//! - **Sensitive Data Isolation**: Sensitive information (e.g., passwords, keys) must use dedicated `_sensitive` macros and is isolated to internal logs only—never exposed in external error messages.
56//! - **No External Sensitive Logging**: Sensitive data is structurally separated and cannot be accidentally included in public-facing error details by convention.
57//! - **Pure Macro Expansion**: Macros expand to pure expressions without side effects, I/O, or runtime dependencies beyond standard library.
58//! - **Defense in Depth**: Multiple layers including literal requirements, sanitization, and separation ensure even if one layer fails, others protect.
59//! - **Fail-Safe Defaults**: Invalid UTF-8 or truncation failures default to safe placeholders like "[INVALID_UTF8]" instead of panicking or leaking.
60//! - **No Side Channels**: Sanitization is deterministic and linear-time relative to input length (up to bound), avoiding attacker-controlled amplification.
61//! - **Dumb-Proof Design**: By requiring explicit `sanitized!()` for args and literals for formats, accidental misuse (e.g., logging raw sensitive data externally) fails at compile time or produces safe output.
62//!
63//! Note: While format! allocates, this is acceptable for error paths. For hot paths, consider pre-formatted strings.
64
65// ============================================================================
66// Sanitization Utilities
67// ============================================================================
68
69/// Maximum length for sanitized strings in error messages.
70///
71/// This prevents DoS attacks via extremely long error messages while still
72/// allowing enough context for debugging.
73pub const MAX_SANITIZED_LEN: usize = 256;
74
75/// Sanitize untrusted input for inclusion in error messages.
76///
77/// # Behavior
78/// - Truncates strings to MAX_SANITIZED_LEN characters, respecting UTF-8 boundaries.
79/// - Replaces control characters with '?' to prevent log injection or formatting issues.
80/// - Handles non-string types by converting to string first.
81/// - For fully control-char inputs exceeding length, uses "[INVALID_INPUT]".
82///
83/// # Allocation
84/// - Allocates a new String for the sanitized output.
85///
86/// # Example
87///
88/// ```rust
89/// # use palisade_errors::sanitized;
90/// let long = "A".repeat(300);
91/// let san = sanitized!(long);
92/// assert!(san.len() <= 256 + 13);
93/// assert!(san.ends_with("[TRUNCATED]"));
94/// ```
95#[macro_export]
96macro_rules! sanitized {
97 ($expr:expr) => {{
98 let original = $expr.to_string();
99 let max_len = $crate::convenience::MAX_SANITIZED_LEN;
100
101 let mut s = String::with_capacity(max_len.min(original.len()));
102 let mut len = 0;
103 let mut truncated = false;
104 let mut saw_non_control = false;
105 let mut in_escape = false;
106
107 for c in original.chars() {
108 if in_escape {
109 if c == 'm' {
110 in_escape = false;
111 }
112 continue;
113 }
114
115 if c == '\u{1b}' {
116 in_escape = true;
117 let replacement = '?';
118 let char_len = replacement.len_utf8();
119 if len + char_len > max_len {
120 truncated = true;
121 break;
122 }
123 s.push(replacement);
124 len += char_len;
125 continue;
126 }
127
128 let replacement = if c.is_control() { '?' } else { c };
129 if !c.is_control() {
130 saw_non_control = true;
131 }
132 let char_len = replacement.len_utf8();
133
134 if len + char_len > max_len {
135 truncated = true;
136 break;
137 }
138
139 s.push(replacement);
140 len += char_len;
141 }
142
143 if !saw_non_control {
144 s = String::from("[INVALID_INPUT]");
145 } else if truncated {
146 // 13 is length of "...[TRUNCATED]"
147 let mut new_len = max_len.saturating_sub(13);
148 while new_len > 0 && !s.is_char_boundary(new_len) {
149 new_len -= 1;
150 }
151 if len > new_len {
152 s.truncate(new_len);
153 }
154 if !s.is_empty() {
155 s.push_str("...[TRUNCATED]");
156 } else {
157 s = String::from("[INVALID_INPUT]");
158 }
159 }
160
161 s
162 }};
163}
164
165// ============================================================================
166// Internal Helper Macro
167// ============================================================================
168
169#[macro_export]
170macro_rules! create_lie_error {
171 ($prefix:literal, $code:expr, $op:literal, $details:expr) => {
172 {
173 let details = $details;
174 let internal = format!("{} op '{}': {}", $prefix, $op, details);
175 $crate::DualContextError::with_lie(details, internal, $code.category())
176 }
177 };
178}
179
180// ============================================================================
181// Error Creation Macros
182// ============================================================================
183
184/// Create a configuration error with compile-time literal enforcement.
185///
186/// Uses DualContextError::with_lie for public deception.
187///
188/// # Arguments
189/// - `$code`: &ErrorCode (expression)
190/// - `$op`: Operation name (string literal)
191/// - `$details`: Public details (string literal or format literal)
192/// - `$args`: Optional sanitized arguments (must use sanitized!())
193///
194/// # Security
195/// - Public: Deceptive lie from $details
196/// - Internal: Diagnostic with operation context
197///
198/// # Example
199///
200/// ```rust
201/// # use palisade_errors::{config_err, definitions, sanitized};
202/// let value = 42;
203/// let err = config_err!(
204/// &definitions::CFG_INVALID_VALUE,
205/// "validate_threshold",
206/// "Invalid configuration value: {}",
207/// sanitized!(value)
208/// );
209/// ```
210#[macro_export]
211macro_rules! config_err {
212 ($code:expr, $op:literal, $details:literal) => {
213 $crate::create_lie_error!("Configuration", $code, $op, $details)
214 };
215 ($code:expr, $op:literal, $fmt:literal $(, sanitized!($arg:expr))+ $(,)?) => {
216 {
217 let details = format!($fmt $(, $crate::sanitized!($arg))+);
218 $crate::create_lie_error!("Configuration", $code, $op, details)
219 }
220 };
221}
222
223/// Create a configuration error with sensitive internal context.
224///
225/// # Arguments
226/// - `$code`: &ErrorCode
227/// - `$op`: Operation literal
228/// - `$public`: Public deceptive literal or format
229/// - `$sensitive`: Sensitive data for internal only (recommend sanitizing)
230///
231/// # Security
232/// - Public: Lie from $public
233/// - Internal: Sensitive with operation
234///
235/// # Example
236///
237/// ```rust
238/// # use palisade_errors::{config_err_sensitive, definitions, sanitized};
239/// let secret = "key";
240/// let err = config_err_sensitive!(
241/// &definitions::CFG_VALIDATION_FAILED,
242/// "auth",
243/// "Configuration invalid",
244/// sanitized!(secret)
245/// );
246/// ```
247#[macro_export]
248macro_rules! config_err_sensitive {
249 ($code:expr, $op:literal, $public:literal, $sensitive:expr) => {
250 $crate::DualContextError::with_lie_and_sensitive(
251 $public,
252 format!("Operation '{}': [SENSITIVE] {}", $op, $sensitive),
253 $code.category(),
254 )
255 };
256 ($code:expr, $op:literal, $fmt:literal, $sensitive:expr $(, sanitized!($arg:expr))+ $(,)?) => {
257 $crate::DualContextError::with_lie_and_sensitive(
258 format!($fmt $(, $crate::sanitized!($arg))+),
259 format!("Operation '{}': [SENSITIVE] {}", $op, $sensitive),
260 $code.category(),
261 )
262 };
263}
264
265/// Create a deployment error.
266///
267/// Maps to Deployment category, uses with_lie.
268#[macro_export]
269macro_rules! deployment_err {
270 ($code:expr, $op:literal, $details:literal) => {
271 $crate::create_lie_error!("Deployment", $code, $op, $details)
272 };
273 ($code:expr, $op:literal, $fmt:literal $(, sanitized!($arg:expr))+ $(,)?) => {
274 {
275 let details = format!($fmt $(, $crate::sanitized!($arg))+);
276 $crate::create_lie_error!("Deployment", $code, $op, details)
277 }
278 };
279}
280
281/// Create a telemetry error.
282///
283/// Maps to Monitoring category.
284#[macro_export]
285macro_rules! telemetry_err {
286 ($code:expr, $op:literal, $details:literal) => {
287 $crate::create_lie_error!("Telemetry", $code, $op, $details)
288 };
289 ($code:expr, $op:literal, $fmt:literal $(, sanitized!($arg:expr))+ $(,)?) => {
290 {
291 let details = format!($fmt $(, $crate::sanitized!($arg))+);
292 $crate::create_lie_error!("Telemetry", $code, $op, details)
293 }
294 };
295}
296
297/// Create a correlation error.
298///
299/// Maps to Analysis category.
300#[macro_export]
301macro_rules! correlation_err {
302 ($code:expr, $op:literal, $details:literal) => {
303 $crate::create_lie_error!("Correlation", $code, $op, $details)
304 };
305 ($code:expr, $op:literal, $fmt:literal $(, sanitized!($arg:expr))+ $(,)?) => {
306 {
307 let details = format!($fmt $(, $crate::sanitized!($arg))+);
308 $crate::create_lie_error!("Correlation", $code, $op, details)
309 }
310 };
311}
312
313/// Create a response error.
314///
315/// Maps to Response category.
316#[macro_export]
317macro_rules! response_err {
318 ($code:expr, $op:literal, $details:literal) => {
319 $crate::create_lie_error!("Response", $code, $op, $details)
320 };
321 ($code:expr, $op:literal, $fmt:literal $(, sanitized!($arg:expr))+ $(,)?) => {
322 {
323 let details = format!($fmt $(, $crate::sanitized!($arg))+);
324 $crate::create_lie_error!("Response", $code, $op, details)
325 }
326 };
327}
328
329/// Create a logging error.
330///
331/// Maps to Audit category.
332#[macro_export]
333macro_rules! logging_err {
334 ($code:expr, $op:literal, $details:literal) => {
335 $crate::create_lie_error!("Logging", $code, $op, $details)
336 };
337 ($code:expr, $op:literal, $fmt:literal $(, sanitized!($arg:expr))+ $(,)?) => {
338 {
339 let details = format!($fmt $(, $crate::sanitized!($arg))+);
340 $crate::create_lie_error!("Logging", $code, $op, details)
341 }
342 };
343}
344
345/// Create a platform error.
346///
347/// Maps to System category.
348#[macro_export]
349macro_rules! platform_err {
350 ($code:expr, $op:literal, $details:literal) => {
351 $crate::create_lie_error!("Platform", $code, $op, $details)
352 };
353 ($code:expr, $op:literal, $fmt:literal $(, sanitized!($arg:expr))+ $(,)?) => {
354 {
355 let details = format!($fmt $(, $crate::sanitized!($arg))+);
356 $crate::create_lie_error!("Platform", $code, $op, details)
357 }
358 };
359}
360
361/// Create an I/O error.
362///
363/// Maps to IO category.
364#[macro_export]
365macro_rules! io_err {
366 ($code:expr, $op:literal, $details:literal) => {
367 $crate::create_lie_error!("IO", $code, $op, $details)
368 };
369 ($code:expr, $op:literal, $fmt:literal $(, sanitized!($arg:expr))+ $(,)?) => {
370 {
371 let details = format!($fmt $(, $crate::sanitized!($arg))+);
372 $crate::create_lie_error!("IO", $code, $op, details)
373 }
374 };
375}
376
377/// Define error codes with minimal boilerplate.
378///
379/// # Example
380///
381/// ```rust
382/// # use palisade_errors::{define_error_code, OperationCategory, namespaces};
383/// define_error_code!(
384/// CFG_PARSE_FAILED,
385/// &namespaces::CFG,
386/// 100,
387/// OperationCategory::Configuration,
388/// 350
389/// );
390/// ```
391#[macro_export]
392macro_rules! define_error_code {
393 ($name:ident, $namespace:expr, $code:expr, $category:expr, $impact:expr) => {
394 pub const $name: $crate::ErrorCode = $crate::ErrorCode::const_new(
395 $namespace,
396 $code,
397 $category,
398 $crate::ImpactScore::new($impact),
399 );
400 };
401}
402
403/// Define multiple error codes within the same namespace.
404///
405/// # Example
406///
407/// ```rust
408/// # use palisade_errors::{define_error_codes, OperationCategory, namespaces};
409/// define_error_codes! {
410/// &namespaces::CFG, OperationCategory::Configuration => {
411/// CFG_PARSE_FAILED = (100, 350),
412/// CFG_VALIDATION_FAILED = (101, 250),
413/// }
414/// }
415/// ```
416#[macro_export]
417macro_rules! define_error_codes {
418 ($namespace:expr, $category:expr => { $( $name:ident = ($code:expr, $impact:expr) ),+ $(,)? }) => {
419 $(
420 $crate::define_error_code!($name, $namespace, $code, $category, $impact);
421 )+
422 };
423}
424
425// ============================================================================
426// Tests
427// ============================================================================
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use crate::definitions;
433 use crate::SocAccess;
434
435 #[test]
436 fn test_literal_enforcement_compiles() {
437 // These should compile
438 let _err1 = config_err!(&definitions::CFG_PARSE_FAILED, "test_op", "test details");
439 let line = 42;
440 let _err2 = config_err!(&definitions::CFG_PARSE_FAILED, "test_op", "line {}", sanitized!(line));
441 }
442
443 #[test]
444 fn sanitized_macro_truncates_long_strings() {
445 let long_string = "A".repeat(1000);
446 let sanitized = sanitized!(long_string);
447
448 assert!(sanitized.len() <= MAX_SANITIZED_LEN + 13); // "...[TRUNCATED]"
449 assert!(sanitized.contains("[TRUNCATED]"));
450 }
451
452 #[test]
453 fn sanitized_macro_preserves_short_strings() {
454 let short_string = "short";
455 let sanitized = sanitized!(short_string);
456
457 assert_eq!(sanitized, "short");
458 }
459
460 #[test]
461 fn sanitized_macro_respects_utf8_boundaries() {
462 let emoji = "🔥".repeat(100);
463 let sanitized = sanitized!(emoji);
464
465 assert!(std::str::from_utf8(sanitized.as_bytes()).is_ok());
466 }
467
468 #[test]
469 fn sanitized_macro_replaces_control_chars() {
470 let input = "hello\nworld\t\x07";
471 let sanitized = sanitized!(input);
472
473 assert_eq!(sanitized, "hello?world??");
474 }
475
476 #[test]
477 fn sanitized_macro_handles_invalid_start() {
478 let input = "\x07".repeat(300);
479 let sanitized = sanitized!(input);
480
481 assert_eq!(sanitized, "[INVALID_INPUT]");
482 }
483
484 #[test]
485 fn sanitized_macro_works_with_numbers() {
486 let num = 42;
487 let sanitized = sanitized!(num);
488
489 assert_eq!(sanitized, "42");
490 }
491
492 #[test]
493 fn error_macros_with_sanitized_args() {
494 let value = "untrusted".repeat(100);
495 let err = config_err!(
496 &definitions::CFG_INVALID_VALUE,
497 "validate",
498 "Invalid value: {}",
499 sanitized!(value)
500 );
501
502 assert!(err.external_message().len() <= MAX_SANITIZED_LEN + 20); // "Invalid value: " + truncated
503 }
504
505 #[test]
506 fn config_err_sensitive_with_sanitization() {
507 let password = "secret123";
508 let err = config_err_sensitive!(
509 &definitions::CFG_VALIDATION_FAILED,
510 "auth",
511 "Auth failed",
512 sanitized!(format!("pwd_len={}", password.len()))
513 );
514
515 assert_eq!(err.external_message(), "Auth failed");
516 let access = SocAccess::acquire();
517 let sensitive = err.internal().expose_sensitive(&access).unwrap();
518 assert!(sensitive.contains("pwd_len=9"));
519 }
520
521 #[test]
522 fn all_error_macros_compile() {
523 let val = "test";
524
525 let _e1 = config_err!(&definitions::CFG_PARSE_FAILED, "op", "details");
526 let _e2 = deployment_err!(&definitions::DCP_DEPLOY_FAILED, "op", "details");
527 let _e3 = telemetry_err!(&definitions::TEL_INIT_FAILED, "op", "details");
528 let _e4 = correlation_err!(&definitions::COR_RULE_EVAL_FAILED, "op", "details");
529 let _e5 = response_err!(&definitions::RSP_EXEC_FAILED, "op", "details");
530 let _e6 = logging_err!(&definitions::LOG_WRITE_FAILED, "op", "details");
531 let _e7 = platform_err!(&definitions::PLT_UNSUPPORTED, "op", "details");
532 let _e8 = io_err!(&definitions::IO_TIMEOUT, "op", "details");
533
534 // With format
535 let _e9 = config_err!(&definitions::CFG_PARSE_FAILED, "op", "val: {}", sanitized!(val));
536 }
537
538 #[test]
539 fn macros_accept_trailing_comma() {
540 let value = 42;
541 let _err = config_err!(
542 &definitions::CFG_PARSE_FAILED,
543 "test",
544 "Value: {}",
545 sanitized!(value),
546 );
547 }
548
549 #[test]
550 fn sanitized_with_pathological_utf8() {
551 let s = "Ñ".repeat(128); // Each 2 bytes, total 256 bytes
552 let sanitized = sanitized!(s);
553
554 assert_eq!(sanitized.len(), 256);
555 assert!(std::str::from_utf8(sanitized.as_bytes()).is_ok());
556 }
557
558 #[test]
559 fn sanitized_with_mixed_controls() {
560 let s = "normal\x1b[0m escape \r\n sequence";
561 let sanitized = sanitized!(s);
562 assert_eq!(sanitized, "normal? escape ?? sequence");
563 }
564}