Skip to main content

appcore_log/
policy.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: policy.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: unknown by dnettoRaw
7//    ##   ## ##   ##    U: 2026/09/04 12:12:57 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Filtering and sanitization run before ordinary sinks receive an event.
12
13use crate::event::LogEvent;
14use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16use std::path::Path;
17
18/// Explicit information-handling policy, never inferred from verbosity.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum Sensitivity {
21    /// Shareable default output.
22    Safe,
23    /// More technical but still redacted output.
24    Diagnostic,
25    /// Encrypted diagnostics only.
26    Sensitive,
27}
28
29/// Explicit local path aliases used by typed path fields.
30#[derive(Debug, Clone, Default)]
31pub struct PathAliases {
32    /// Application root if known.
33    pub app_root: Option<String>,
34    /// User home if allowed to be recognized.
35    pub home: Option<String>,
36    /// Temporary directory if known.
37    pub temp: Option<String>,
38    /// Data directory if known.
39    pub data: Option<String>,
40    /// Cache directory if known.
41    pub cache: Option<String>,
42}
43
44/// Filters events before formatting and sanitizes values before ordinary sinks.
45#[derive(Debug, Clone)]
46pub struct LogPolicy {
47    global: u8,
48    components: BTreeMap<String, u8>,
49    /// Enables full typed-path output only by explicit configuration.
50    pub full_paths: bool,
51    /// Safe, Diagnostic or explicit encrypted Sensitive output.
52    pub sensitivity: Sensitivity,
53    /// Trusted aliases for typed local path fields.
54    pub paths: PathAliases,
55}
56
57impl Default for LogPolicy {
58    fn default() -> Self {
59        Self::new(crate::Verbosity::V4)
60    }
61}
62
63impl LogPolicy {
64    /// Creates a safe policy with one global verbosity threshold.
65    pub fn new(verbosity: crate::Verbosity) -> Self {
66        Self {
67            global: verbosity.value(),
68            components: BTreeMap::new(),
69            full_paths: false,
70            sensitivity: Sensitivity::Safe,
71            paths: PathAliases::default(),
72        }
73    }
74    /// Overrides a component threshold without changing the global threshold.
75    pub fn set_component(&mut self, component: impl Into<String>, verbosity: crate::Verbosity) {
76        self.components.insert(component.into(), verbosity.value());
77    }
78    /// Reports whether an event is selected before serialization or sink I/O.
79    pub fn allows(&self, event: &LogEvent) -> bool {
80        self.allows_component(&event.component, event.verbosity)
81    }
82
83    /// Reports whether a component and verbosity are selected without an event.
84    pub(crate) fn allows_component(&self, component: &str, verbosity: crate::Verbosity) -> bool {
85        let configured = component_threshold(&self.components, component).unwrap_or(self.global);
86        verbosity.value() <= configured
87    }
88    /// Produces the event accepted by its configured sensitivity boundary.
89    ///
90    /// In Safe and Diagnostic modes all normal sinks receive redacted secrets
91    /// and aliased typed paths. Sensitive mode is routed only to DNT sinks by
92    /// the dispatcher; fields marked with [`LogEvent::secret`] remain redacted
93    /// even there because they represent prohibited credential material.
94    pub fn sanitize(&self, event: &LogEvent) -> LogEvent {
95        self.sanitize_owned(event.clone())
96    }
97
98    /// Sanitizes an owned event without cloning its message or metadata.
99    pub fn sanitize_owned(&self, mut value: LogEvent) -> LogEvent {
100        if self.sensitivity != Sensitivity::Sensitive {
101            value.message = redact_text(value.message);
102        }
103        for field in &mut value.fields {
104            if field.sensitive {
105                field.value = "<REDACTED>".to_string();
106            } else if field.path && !self.full_paths && self.sensitivity != Sensitivity::Sensitive {
107                field.value = alias_path(&field.value, &self.paths);
108            }
109        }
110        value
111    }
112
113    /// Returns the warning an application must surface when it enables
114    /// encrypted sensitive diagnostics.
115    pub const fn sensitive_warning() -> &'static str {
116        "Sensitive logging is active: encrypted diagnostics may contain confidential material."
117    }
118}
119
120fn component_threshold(components: &BTreeMap<String, u8>, component: &str) -> Option<u8> {
121    let mut candidate = component;
122    loop {
123        if let Some(verbosity) = components.get(candidate) {
124            return Some(*verbosity);
125        }
126        let (parent, _) = candidate.rsplit_once('.')?;
127        candidate = parent;
128    }
129}
130
131fn alias_path(value: &str, aliases: &PathAliases) -> String {
132    for (prefix, alias) in [
133        (&aliases.app_root, "<APP_ROOT>"),
134        (&aliases.home, "<HOME>"),
135        (&aliases.temp, "<TEMP>"),
136        (&aliases.cache, "<CACHE>"),
137        (&aliases.data, "<DATA>"),
138    ] {
139        if let Some(prefix) = prefix.as_deref() {
140            if Path::new(value).starts_with(prefix) {
141                return value.replacen(prefix, alias, 1);
142            }
143        }
144    }
145    "<LOCAL_PATH>".to_string()
146}
147
148fn redact_text(message: String) -> String {
149    if !message
150        .split_whitespace()
151        .any(|token| redaction_for_token(token).is_some())
152    {
153        return message;
154    }
155    let mut redacted = String::with_capacity(message.len());
156    for (index, token) in message.split_whitespace().enumerate() {
157        if index != 0 {
158            redacted.push(' ');
159        }
160        redacted.push_str(redaction_for_token(token).unwrap_or(token));
161    }
162    redacted
163}
164
165fn redaction_for_token(token: &str) -> Option<&'static str> {
166    let secret_markers = [
167        "token=",
168        "password=",
169        "secret=",
170        "api_key=",
171        "apikey=",
172        "authorization:",
173        "cookie:",
174        "set-cookie:",
175        "session=",
176        "private_key=",
177    ];
178    if secret_markers
179        .iter()
180        .any(|marker| contains_ascii_case_insensitive(token, marker))
181    {
182        return Some("<REDACTED>");
183    }
184    if token.contains("://") && token.contains('@') {
185        return Some("<REDACTED_URL>");
186    }
187    None
188}
189
190fn contains_ascii_case_insensitive(value: &str, pattern: &str) -> bool {
191    value.as_bytes().windows(pattern.len()).any(|window| {
192        window
193            .iter()
194            .zip(pattern.as_bytes())
195            .all(|(left, right)| left.eq_ignore_ascii_case(right))
196    })
197}