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='",
176            self.code,
177            if self.retryable { "[RETRYABLE]" } else { "" },
178        )?;
179        write_truncated(f, self.operation)?;
180        f.write_str("' details='")?;
181        write_truncated(f, self.details)?;
182        f.write_str("'")?;
183
184        if let Some(internal) = self.source_internal {
185            f.write_str(" source='")?;
186            write_truncated(f, internal)?;
187            f.write_str("'")?;
188        }
189
190        if let Some(sensitive) = self.source_sensitive {
191            f.write_str(" sensitive='")?;
192            write_truncated(f, sensitive)?;
193            f.write_str("'")?;
194        }
195
196        for (key, value) in self.metadata {
197            write!(f, " {}='", key)?;
198            write_truncated(f, value.as_str())?;
199            f.write_str("'")?;
200        }
201
202        Ok(())
203    }
204
205    /// Access structured fields for JSON/structured logging.
206    ///
207    /// Preferred over string formatting because it allows the logging
208    /// framework to handle sensitive data according to its own policies.
209    ///
210    /// Note: Fields are not truncated here - truncation is the responsibility
211    /// of the logging framework when serializing to its output format.
212    #[inline]
213    pub const fn code(&self) -> &ErrorCode {
214        self.code
215    }
216
217    #[inline]
218    pub const fn operation(&self) -> &str {
219        self.operation
220    }
221
222    #[inline]
223    pub const fn details(&self) -> &str {
224        self.details
225    }
226
227    #[inline]
228    pub const fn source_internal(&self) -> Option<&str> {
229        self.source_internal
230    }
231
232    #[inline]
233    pub const fn source_sensitive(&self) -> Option<&str> {
234        self.source_sensitive
235    }
236
237    /// Get metadata fields - zero-cost accessor.
238    ///
239    /// PERFORMANCE FIX: Removed enforce_metadata_floor() that was causing
240    /// 15ms delays. Timing obfuscation should be done once during error
241    /// construction, not on every field access.
242    #[inline]
243    pub const fn metadata(&self) -> &[(&'static str, ContextField)] {
244        self.metadata
245    }
246
247    #[inline]
248    pub const fn is_retryable(&self) -> bool {
249        self.retryable
250    }
251}
252
253/// Truncate a string for display to prevent DoS via extremely long error messages.
254///
255/// If the string exceeds MAX_FIELD_OUTPUT_LEN, it's truncated with an indicator
256/// to make the truncation visible to operators.
257///
258/// Returns a Cow<str> to avoid allocation when no truncation is needed.
259fn truncate_with_indicator(s: &str) -> Cow<'_, str> {
260    if s.len() <= MAX_FIELD_OUTPUT_LEN {
261        return Cow::Borrowed(s);
262    }
263
264    // Reserve space for the truncation indicator
265    let max_content_len = MAX_FIELD_OUTPUT_LEN.saturating_sub(TRUNCATION_INDICATOR.len());
266
267    // Find the last valid UTF-8 character boundary at or before the limit
268    let mut idx = max_content_len;
269    while idx > 0 && !s.is_char_boundary(idx) {
270        idx -= 1;
271    }
272
273    // If we couldn't find a boundary (pathological case), just use the indicator
274    if idx == 0 {
275        return Cow::Borrowed(TRUNCATION_INDICATOR);
276    }
277
278    // Allocate a new string with the truncated content + indicator
279    let mut result = String::with_capacity(idx + TRUNCATION_INDICATOR.len());
280    result.push_str(&s[..idx]);
281    result.push_str(TRUNCATION_INDICATOR);
282    Cow::Owned(result)
283}
284
285#[inline]
286fn write_truncated(f: &mut impl fmt::Write, s: &str) -> fmt::Result {
287    if s.len() <= MAX_FIELD_OUTPUT_LEN {
288        return f.write_str(s);
289    }
290
291    let max_content_len = MAX_FIELD_OUTPUT_LEN.saturating_sub(TRUNCATION_INDICATOR.len());
292    let mut idx = max_content_len;
293    while idx > 0 && !s.is_char_boundary(idx) {
294        idx -= 1;
295    }
296
297    if idx == 0 {
298        return f.write_str(TRUNCATION_INDICATOR);
299    }
300
301    f.write_str(&s[..idx])?;
302    f.write_str(TRUNCATION_INDICATOR)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn truncate_ascii() {
311        let s = "a".repeat(MAX_FIELD_OUTPUT_LEN + 10);
312        let truncated = truncate_with_indicator(&s);
313
314        // Should be truncated
315        assert!(truncated.len() <= MAX_FIELD_OUTPUT_LEN);
316
317        // Should contain truncation indicator
318        assert!(truncated.ends_with(TRUNCATION_INDICATOR));
319    }
320
321    #[test]
322    fn no_truncate_when_under_limit() {
323        let s = "short string";
324        let truncated = truncate_with_indicator(s);
325
326        // Should be borrowed (no allocation)
327        assert!(matches!(truncated, Cow::Borrowed(_)));
328        assert_eq!(truncated, s);
329    }
330
331    #[test]
332    fn truncate_utf8_boundary() {
333        // Russian text: каждый символ занимает 2 байта
334        let s = "й".repeat(MAX_FIELD_OUTPUT_LEN); // Each 'й' is 2 bytes
335        let truncated = truncate_with_indicator(&s);
336
337        // Should not panic, should be valid UTF-8
338        let _ = truncated.to_string();
339
340        // Length should be at most MAX_FIELD_OUTPUT_LEN
341        assert!(truncated.len() <= MAX_FIELD_OUTPUT_LEN);
342
343        // Should end with truncation indicator
344        assert!(truncated.ends_with(TRUNCATION_INDICATOR));
345    }
346
347    #[test]
348    fn truncate_emoji() {
349        let s = "🔥".repeat(MAX_FIELD_OUTPUT_LEN); // Each emoji is 4 bytes
350        let truncated = truncate_with_indicator(&s);
351
352        // Must remain valid UTF-8
353        assert!(std::str::from_utf8(truncated.as_bytes()).is_ok());
354
355        // Should contain truncation indicator
356        assert!(truncated.ends_with(TRUNCATION_INDICATOR));
357    }
358
359    #[test]
360    fn exactly_at_limit() {
361        let s = "a".repeat(MAX_FIELD_OUTPUT_LEN);
362        let truncated = truncate_with_indicator(&s);
363
364        // Should NOT be truncated (exactly at limit)
365        assert!(matches!(truncated, Cow::Borrowed(_)));
366        assert_eq!(truncated.len(), MAX_FIELD_OUTPUT_LEN);
367        assert!(!truncated.ends_with(TRUNCATION_INDICATOR));
368    }
369
370    #[test]
371    fn one_over_limit() {
372        let s = "a".repeat(MAX_FIELD_OUTPUT_LEN + 1);
373        let truncated = truncate_with_indicator(&s);
374
375        // Should be truncated
376        assert!(matches!(truncated, Cow::Owned(_)));
377        assert!(truncated.len() <= MAX_FIELD_OUTPUT_LEN);
378        assert!(truncated.ends_with(TRUNCATION_INDICATOR));
379    }
380
381    #[test]
382    fn context_field_zeroizes_owned() {
383        let mut field = ContextField::from(String::from("sensitive"));
384
385        // Verify it's owned
386        assert!(matches!(field.value, Cow::Owned(_)));
387
388        // Zeroize manually (normally done in Drop)
389        field.zeroize();
390
391        // Value should be empty after zeroization
392        assert_eq!(field.as_str(), "");
393    }
394
395    #[test]
396    fn context_field_doesnt_zeroize_borrowed() {
397        let mut field = ContextField::from("static");
398
399        // Verify it's borrowed
400        assert!(matches!(field.value, Cow::Borrowed(_)));
401
402        // Zeroize should be no-op for borrowed
403        field.zeroize();
404
405        // Value should still be intact (static string)
406        assert_eq!(field.as_str(), "static");
407    }
408}