Skip to main content

palisade_errors/
logging.rs

1//! Structured log entry for internal forensics.
2//!
3//! # Critical Security Properties
4//!
5//! - Borrows from AgentError with explicit lifetime
6//! - CANNOT outlive the error that created it
7//! - Forces immediate consumption by logger
8//! - NO heap allocations in accessors
9//! - NO leaked memory
10//! - NO escaped references
11//!
12//! This structure exists only during the logging call and is destroyed
13//! immediately afterward, ensuring all sensitive data is zeroized when
14//! the error drops.
15//!
16//! The short lifetime is a FEATURE, not a limitation. It enforces that
17//! sensitive data cannot be retained beyond its intended scope.
18
19use crate::ErrorCode;
20use std::borrow::Cow;
21use std::fmt;
22use zeroize::Zeroize;
23
24/// Maximum length for any individual field in formatted output (DoS prevention)
25const MAX_FIELD_OUTPUT_LEN: usize = 1024;
26
27/// Truncation indicator appended to truncated strings
28const TRUNCATION_INDICATOR: &str = "...[TRUNCATED]";
29
30/// Metadata value wrapper with zeroization for owned data.
31///
32/// Borrowed values are assumed static and are not zeroized.
33#[derive(Debug)]
34pub struct ContextField {
35    value: Cow<'static, str>,
36}
37
38impl ContextField {
39    #[inline]
40    pub fn as_str(&self) -> &str {
41        self.value.as_ref()
42    }
43}
44
45impl From<&'static str> for ContextField {
46    fn from(value: &'static str) -> Self {
47        Self {
48            value: Cow::Borrowed(value),
49        }
50    }
51}
52
53impl From<String> for ContextField {
54    fn from(value: String) -> Self {
55        Self {
56            value: Cow::Owned(value),
57        }
58    }
59}
60
61impl From<Cow<'static, str>> for ContextField {
62    fn from(value: Cow<'static, str>) -> Self {
63        Self { value }
64    }
65}
66
67impl Zeroize for ContextField {
68    fn zeroize(&mut self) {
69        if let Cow::Owned(ref mut s) = self.value {
70            s.zeroize();
71        }
72    }
73}
74
75impl Drop for ContextField {
76    fn drop(&mut self) {
77        self.zeroize();
78    }
79}
80
81/// Structured log entry with borrowed data from AgentError.
82///
83/// This struct has an explicit lifetime parameter that ties it to the
84/// error that created it, preventing the log from outliving the error.
85///
86/// # Example
87///
88/// ```rust
89/// # use palisade_errors::{AgentError, definitions};
90/// let err = AgentError::config(definitions::CFG_PARSE_FAILED, "test", "details");
91/// let log = err.internal_log();
92/// // Use log immediately
93/// // log is destroyed when it goes out of scope
94/// ```
95#[derive(Debug)]
96pub struct InternalLog<'a> {
97    pub code: &'a ErrorCode,
98    pub operation: &'a str,
99    pub details: &'a str,
100    pub source_internal: Option<&'a str>,
101    pub source_sensitive: Option<&'a str>,
102    pub metadata: &'a [(&'static str, ContextField)],
103    pub retryable: bool,
104}
105
106impl<'a> InternalLog<'a> {
107    /// Format for human-readable logs in trusted debug contexts.
108    ///
109    /// WARNING: This materializes sensitive data into a String.
110    /// This function is only available with BOTH the `trusted_debug` feature flag
111    /// AND debug assertions enabled. This prevents accidental use in production.
112    ///
113    /// Only use this in:
114    /// - Local development debugging
115    /// - Trusted internal logging systems with proper access controls
116    /// - Post-mortem forensic analysis in secure environments
117    ///
118    /// NEVER use in:
119    /// - External-facing logs
120    /// - Untrusted log aggregation
121    /// - Production without proper sanitization pipeline
122    #[cfg(all(feature = "trusted_debug", debug_assertions))]
123    pub fn format_for_trusted_debug(&self) -> String {
124        let mut output = format!(
125            "[{}] {} operation='{}' details='{}'",
126            self.code,
127            if self.retryable { "[RETRYABLE]" } else { "" },
128            truncate_with_indicator(self.operation),
129            truncate_with_indicator(self.details)
130        );
131
132        if let Some(internal) = self.source_internal {
133            output.push_str(&format!(
134                " source='{}'",
135                truncate_with_indicator(internal)
136            ));
137        }
138
139        if let Some(sensitive) = self.source_sensitive {
140            output.push_str(&format!(
141                " sensitive='{}'",
142                truncate_with_indicator(sensitive)
143            ));
144        }
145
146        for (key, value) in self.metadata {
147            output.push_str(&format!(
148                " {}='{}'",
149                key,
150                truncate_with_indicator(value.as_str())
151            ));
152        }
153
154        output
155    }
156
157    /// Write structured log data to a formatter without allocating.
158    ///
159    /// This is the preferred method for production logging as it:
160    /// - Does not allocate strings for sensitive data
161    /// - Writes directly to the output
162    /// - Allows the logging framework to control serialization
163    /// - Truncates fields to prevent DoS via memory exhaustion
164    ///
165    /// Example:
166    /// ```rust,ignore
167    /// err.with_internal_log(|log| {
168    ///     let mut buffer = String::new();
169    ///     log.write_to(&mut buffer).unwrap();
170    /// });
171    /// ```
172    pub fn write_to(&self, f: &mut impl fmt::Write) -> fmt::Result {
173        write!(
174            f,
175            "[{}] {} operation='{}' details='{}'",
176            self.code,
177            if self.retryable { "[RETRYABLE]" } else { "" },
178            truncate_with_indicator(self.operation),
179            truncate_with_indicator(self.details)
180        )?;
181
182        if let Some(internal) = self.source_internal {
183            write!(f, " source='{}'", truncate_with_indicator(internal))?;
184        }
185
186        if let Some(sensitive) = self.source_sensitive {
187            write!(f, " sensitive='{}'", truncate_with_indicator(sensitive))?;
188        }
189
190        for (key, value) in self.metadata {
191            write!(
192                f,
193                " {}='{}'",
194                key,
195                truncate_with_indicator(value.as_str())
196            )?;
197        }
198
199        Ok(())
200    }
201
202    /// Access structured fields for JSON/structured logging.
203    ///
204    /// Preferred over string formatting because it allows the logging
205    /// framework to handle sensitive data according to its own policies.
206    ///
207    /// Note: Fields are not truncated here - truncation is the responsibility
208    /// of the logging framework when serializing to its output format.
209    #[inline]
210    pub const fn code(&self) -> &ErrorCode {
211        self.code
212    }
213
214    #[inline]
215    pub const fn operation(&self) -> &str {
216        self.operation
217    }
218
219    #[inline]
220    pub const fn details(&self) -> &str {
221        self.details
222    }
223
224    #[inline]
225    pub const fn source_internal(&self) -> Option<&str> {
226        self.source_internal
227    }
228
229    #[inline]
230    pub const fn source_sensitive(&self) -> Option<&str> {
231        self.source_sensitive
232    }
233
234    /// Get metadata fields - zero-cost accessor.
235    ///
236    /// PERFORMANCE FIX: Removed enforce_metadata_floor() that was causing
237    /// 15ms delays. Timing obfuscation should be done once during error
238    /// construction, not on every field access.
239    #[inline]
240    pub const fn metadata(&self) -> &[(&'static str, ContextField)] {
241        self.metadata
242    }
243
244    #[inline]
245    pub const fn is_retryable(&self) -> bool {
246        self.retryable
247    }
248}
249
250/// Truncate a string for display to prevent DoS via extremely long error messages.
251///
252/// If the string exceeds MAX_FIELD_OUTPUT_LEN, it's truncated with an indicator
253/// to make the truncation visible to operators.
254///
255/// Returns a Cow<str> to avoid allocation when no truncation is needed.
256fn truncate_with_indicator(s: &str) -> Cow<'_, str> {
257    if s.len() <= MAX_FIELD_OUTPUT_LEN {
258        return Cow::Borrowed(s);
259    }
260
261    // Reserve space for the truncation indicator
262    let max_content_len = MAX_FIELD_OUTPUT_LEN.saturating_sub(TRUNCATION_INDICATOR.len());
263
264    // Find the last valid UTF-8 character boundary at or before the limit
265    let mut idx = max_content_len;
266    while idx > 0 && !s.is_char_boundary(idx) {
267        idx -= 1;
268    }
269
270    // If we couldn't find a boundary (pathological case), just use the indicator
271    if idx == 0 {
272        return Cow::Borrowed(TRUNCATION_INDICATOR);
273    }
274
275    // Allocate a new string with the truncated content + indicator
276    let mut result = String::with_capacity(idx + TRUNCATION_INDICATOR.len());
277    result.push_str(&s[..idx]);
278    result.push_str(TRUNCATION_INDICATOR);
279    Cow::Owned(result)
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn truncate_ascii() {
288        let s = "a".repeat(MAX_FIELD_OUTPUT_LEN + 10);
289        let truncated = truncate_with_indicator(&s);
290
291        // Should be truncated
292        assert!(truncated.len() <= MAX_FIELD_OUTPUT_LEN);
293
294        // Should contain truncation indicator
295        assert!(truncated.ends_with(TRUNCATION_INDICATOR));
296    }
297
298    #[test]
299    fn no_truncate_when_under_limit() {
300        let s = "short string";
301        let truncated = truncate_with_indicator(s);
302
303        // Should be borrowed (no allocation)
304        assert!(matches!(truncated, Cow::Borrowed(_)));
305        assert_eq!(truncated, s);
306    }
307
308    #[test]
309    fn truncate_utf8_boundary() {
310        // Russian text: каждый символ занимает 2 байта
311        let s = "й".repeat(MAX_FIELD_OUTPUT_LEN); // Each 'й' is 2 bytes
312        let truncated = truncate_with_indicator(&s);
313
314        // Should not panic, should be valid UTF-8
315        let _ = truncated.to_string();
316
317        // Length should be at most MAX_FIELD_OUTPUT_LEN
318        assert!(truncated.len() <= MAX_FIELD_OUTPUT_LEN);
319
320        // Should end with truncation indicator
321        assert!(truncated.ends_with(TRUNCATION_INDICATOR));
322    }
323
324    #[test]
325    fn truncate_emoji() {
326        let s = "🔥".repeat(MAX_FIELD_OUTPUT_LEN); // Each emoji is 4 bytes
327        let truncated = truncate_with_indicator(&s);
328
329        // Must remain valid UTF-8
330        assert!(std::str::from_utf8(truncated.as_bytes()).is_ok());
331
332        // Should contain truncation indicator
333        assert!(truncated.ends_with(TRUNCATION_INDICATOR));
334    }
335
336    #[test]
337    fn exactly_at_limit() {
338        let s = "a".repeat(MAX_FIELD_OUTPUT_LEN);
339        let truncated = truncate_with_indicator(&s);
340
341        // Should NOT be truncated (exactly at limit)
342        assert!(matches!(truncated, Cow::Borrowed(_)));
343        assert_eq!(truncated.len(), MAX_FIELD_OUTPUT_LEN);
344        assert!(!truncated.ends_with(TRUNCATION_INDICATOR));
345    }
346
347    #[test]
348    fn one_over_limit() {
349        let s = "a".repeat(MAX_FIELD_OUTPUT_LEN + 1);
350        let truncated = truncate_with_indicator(&s);
351
352        // Should be truncated
353        assert!(matches!(truncated, Cow::Owned(_)));
354        assert!(truncated.len() <= MAX_FIELD_OUTPUT_LEN);
355        assert!(truncated.ends_with(TRUNCATION_INDICATOR));
356    }
357
358    #[test]
359    fn context_field_zeroizes_owned() {
360        let mut field = ContextField::from(String::from("sensitive"));
361
362        // Verify it's owned
363        assert!(matches!(field.value, Cow::Owned(_)));
364
365        // Zeroize manually (normally done in Drop)
366        field.zeroize();
367
368        // Value should be empty after zeroization
369        assert_eq!(field.as_str(), "");
370    }
371
372    #[test]
373    fn context_field_doesnt_zeroize_borrowed() {
374        let mut field = ContextField::from("static");
375
376        // Verify it's borrowed
377        assert!(matches!(field.value, Cow::Borrowed(_)));
378
379        // Zeroize should be no-op for borrowed
380        field.zeroize();
381
382        // Value should still be intact (static string)
383        assert_eq!(field.as_str(), "static");
384    }
385}