Skip to main content

netget/protocol/
log_template.rs

1//! Log template system for standardized event and action logging
2//!
3//! Provides a template-based logging system where protocols define log formats
4//! at INFO/DEBUG/TRACE levels. Templates use `{field}` interpolation syntax
5//! to extract values from event/action data.
6
7use regex::Regex;
8use serde_json::Value;
9use std::sync::LazyLock;
10
11/// Log level for template rendering
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum LogLevel {
14    Info,
15    Debug,
16    Trace,
17}
18
19impl LogLevel {
20    /// Get the log level prefix for TUI display
21    pub fn prefix(&self) -> &'static str {
22        match self {
23            LogLevel::Info => "[INFO]",
24            LogLevel::Debug => "[DEBUG]",
25            LogLevel::Trace => "[TRACE]",
26        }
27    }
28}
29
30/// Log template for generating log messages from event/action data
31///
32/// Templates use `{field}` syntax to interpolate values from JSON data.
33/// Each level (info/debug/trace) can have its own template format.
34///
35/// # Example
36/// ```rust,ignore
37/// let template = LogTemplate::new()
38///     .with_info("{client_ip} {method} {path} -> {status}")
39///     .with_debug("HTTP {method} {path} from {client_ip}:{client_port}")
40///     .with_trace("HTTP request: {json_pretty(.)}");
41/// ```
42#[derive(Clone, Debug, Default)]
43pub struct LogTemplate {
44    /// INFO level: One-liner access log style (protocol-specific)
45    /// Example: "{client_ip} {method} {path} -> {status} ({response_bytes}B, {duration_ms}ms)"
46    pub info: Option<String>,
47
48    /// DEBUG level: Extra details + timing
49    /// Example: "HTTP {method} {path} from {client_ip}:{client_port}, headers: {headers_len}"
50    pub debug: Option<String>,
51
52    /// TRACE level: Full request/response details
53    /// Example: "Full HTTP request: {json_pretty(.)}"
54    pub trace: Option<String>,
55}
56
57impl LogTemplate {
58    /// Create a new empty log template
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Set INFO level template
64    ///
65    /// Used for one-liner access log style messages.
66    /// Example: "{client_ip} {method} {path} -> {status}"
67    pub fn with_info(mut self, template: impl Into<String>) -> Self {
68        self.info = Some(template.into());
69        self
70    }
71
72    /// Set DEBUG level template
73    ///
74    /// Used for extra details including timing.
75    /// Example: "HTTP {method} {path} from {client_ip}:{client_port}"
76    pub fn with_debug(mut self, template: impl Into<String>) -> Self {
77        self.debug = Some(template.into());
78        self
79    }
80
81    /// Set TRACE level template
82    ///
83    /// Used for full payload details.
84    /// Example: "Full request: {json_pretty(.)}"
85    pub fn with_trace(mut self, template: impl Into<String>) -> Self {
86        self.trace = Some(template.into());
87        self
88    }
89
90    /// Get the template string for a given log level
91    pub fn get_template(&self, level: LogLevel) -> Option<&str> {
92        match level {
93            LogLevel::Info => self.info.as_deref(),
94            LogLevel::Debug => self.debug.as_deref(),
95            LogLevel::Trace => self.trace.as_deref(),
96        }
97    }
98
99    /// Render the template for a given log level with event/action data
100    ///
101    /// Returns None if no template is defined for the level.
102    ///
103    /// # Arguments
104    /// * `level` - The log level to render
105    /// * `data` - JSON data to interpolate into the template
106    ///
107    /// # Template Syntax
108    /// - `{field}` - Simple field access
109    /// - `{field.subfield}` - Nested field access (dot notation)
110    /// - `{json(field)}` - Compact JSON of field
111    /// - `{json_pretty(.)}` - Pretty-print entire data object
112    /// - `{hex(field)}` - Hex-encode string field
113    /// - `{field_len}` - Length of array/string/object field
114    /// - `{preview(field)}` - First 100 chars with ellipsis
115    /// - `{preview(field,50)}` - First N chars with ellipsis
116    pub fn render(&self, level: LogLevel, data: &Value) -> Option<String> {
117        self.get_template(level).map(|t| render_template(t, data))
118    }
119
120    /// Check if any template is defined
121    pub fn has_any(&self) -> bool {
122        self.info.is_some() || self.debug.is_some() || self.trace.is_some()
123    }
124}
125
126/// Regex for matching template placeholders
127static PLACEHOLDER_REGEX: LazyLock<Regex> =
128    LazyLock::new(|| Regex::new(r"\{([^}]+)\}").expect("Invalid regex"));
129
130/// Render a template string with JSON data
131///
132/// Supports:
133/// - `{field}` - Simple field access
134/// - `{field.subfield}` - Nested field access
135/// - `{json(field)}` - Compact JSON
136/// - `{json_pretty(field)}` or `{json_pretty(.)}` - Pretty JSON
137/// - `{hex(field)}` - Hex-encode bytes
138/// - `{field_len}` - Array/string length
139/// - `{preview(field)}` - First 100 chars
140/// - `{preview(field,N)}` - First N chars
141fn render_template(template: &str, data: &Value) -> String {
142    // Replace matches in the template once. Replacing on the already-rendered string
143    // would interpret placeholder-shaped text inside a peer's data as another field.
144    PLACEHOLDER_REGEX
145        .replace_all(template, |cap: &regex::Captures<'_>| {
146            let placeholder = &cap[1];
147
148            // The template is written by the protocol and is trusted. The *value* substituted into
149            // it is not: it comes from the model's action or from the wire, and this renderer is
150            // what puts it on a log line.
151            //
152            // A control character in it forges a log entry. `\r\nERROR forged` turns one line into
153            // two, and the second is indistinguishable from something NetGet wrote — which is the
154            // whole point of impersonating a device. LLDP, CDP and HSRP each fixed this locally in
155            // their own fields; this is the one place that covers all 140 servers, including the
156            // ones nobody has looked at yet.
157            //
158            // `line_field` substitutes a space rather than deleting, for the reason
159            // `utils::sanitize` documents: deleting joins the two sides into one word, which reads
160            // as a single legitimate value and is its own small lie. The JSON placeholder forms
161            // escape control characters already, so this is a no-op for them.
162            crate::utils::sanitize::line_field(&render_placeholder(placeholder, data))
163        })
164        .into_owned()
165}
166
167/// Render a single placeholder
168fn render_placeholder(placeholder: &str, data: &Value) -> String {
169    // Handle special functions
170    if placeholder == "." {
171        // Entire data object as compact JSON
172        return serde_json::to_string(data).unwrap_or_else(|_| "{}".to_string());
173    }
174
175    if placeholder.starts_with("json_pretty(") && placeholder.ends_with(')') {
176        // Pretty-print JSON
177        let field = &placeholder[12..placeholder.len() - 1];
178        let value = if field == "." {
179            data.clone()
180        } else {
181            get_nested_value(data, field)
182        };
183        return serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string());
184    }
185
186    if placeholder.starts_with("json(") && placeholder.ends_with(')') {
187        // Compact JSON
188        let field = &placeholder[5..placeholder.len() - 1];
189        let value = if field == "." {
190            data.clone()
191        } else {
192            get_nested_value(data, field)
193        };
194        return serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string());
195    }
196
197    if placeholder.starts_with("hex(") && placeholder.ends_with(')') {
198        // Hex encode
199        let field = &placeholder[4..placeholder.len() - 1];
200        let value = get_nested_value(data, field);
201        if let Some(s) = value.as_str() {
202            return hex::encode(s.as_bytes());
203        }
204        return String::new();
205    }
206
207    if placeholder.starts_with("preview(") && placeholder.ends_with(')') {
208        // Preview with optional length
209        let inner = &placeholder[8..placeholder.len() - 1];
210        let (field, max_len) = if let Some(comma_pos) = inner.find(',') {
211            let field = inner[..comma_pos].trim();
212            let len_str = inner[comma_pos + 1..].trim();
213            let len = len_str.parse::<usize>().unwrap_or(100);
214            (field, len)
215        } else {
216            (inner, 100)
217        };
218
219        let value = get_nested_value(data, field);
220        let s = value_to_string(&value);
221        // Must cut on a character boundary: `s` is network text or model output, and
222        // `s.len()` is a byte count, so `&s[..max_len]` panics whenever the cut lands
223        // inside a multi-byte character. This is shared infrastructure — `preview(...)`
224        // is used by irc, pop3, xmpp and others, so the panic defeated those protocols'
225        // own local fixes for the same class.
226        return crate::utils::truncate_for_log(&s, max_len);
227    }
228
229    if placeholder.ends_with("_len") {
230        // Length of field
231        let field = &placeholder[..placeholder.len() - 4];
232        let value = get_nested_value(data, field);
233        return match &value {
234            Value::String(s) => s.len().to_string(),
235            Value::Array(a) => a.len().to_string(),
236            Value::Object(o) => o.len().to_string(),
237            _ => "0".to_string(),
238        };
239    }
240
241    // Simple field access (supports dot notation)
242    let value = get_nested_value(data, placeholder);
243    value_to_string(&value)
244}
245
246/// Get a nested value from JSON using dot notation
247///
248/// Example: `get_nested_value(data, "headers.content_type")`
249fn get_nested_value(data: &Value, path: &str) -> Value {
250    let parts: Vec<&str> = path.split('.').collect();
251    let mut current = data.clone();
252
253    for part in parts {
254        // Handle array index notation like "items[0]"
255        if let Some(bracket_pos) = part.find('[') {
256            let field = &part[..bracket_pos];
257            let index_str = &part[bracket_pos + 1..part.len() - 1];
258            if let Ok(index) = index_str.parse::<usize>() {
259                current = current
260                    .get(field)
261                    .and_then(|v| v.get(index))
262                    .cloned()
263                    .unwrap_or(Value::Null);
264                continue;
265            }
266        }
267
268        current = current.get(part).cloned().unwrap_or(Value::Null);
269    }
270
271    current
272}
273
274/// Convert a JSON value to a display string
275fn value_to_string(value: &Value) -> String {
276    match value {
277        Value::String(s) => s.clone(),
278        Value::Number(n) => n.to_string(),
279        Value::Bool(b) => b.to_string(),
280        Value::Null => String::new(),
281        Value::Array(_) | Value::Object(_) => {
282            serde_json::to_string(value).unwrap_or_else(|_| String::new())
283        }
284    }
285}