#[derive(Debug, Clone, Default)]
pub struct SyslogMeta {
pub pri: Option<u8>,
pub facility: Option<String>,
pub severity: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct Normalized {
#[allow(dead_code)]
pub header: Option<String>,
pub message: String,
pub meta: SyslogMeta,
}
#[derive(Debug, Clone, Default)]
pub struct NormalizedSlice {
pub msg_start: usize,
pub msg_end: usize,
pub meta: SyslogMeta,
}
pub fn normalize_slice(line: &str) -> NormalizedSlice {
if let Some(ns) = parse_rfc5424_slice(line) {
return ns;
}
if let Some(ns) = parse_rfc3164_slice(line) {
return ns;
}
NormalizedSlice {
msg_start: 0,
msg_end: line.len(),
meta: Default::default(),
}
}
pub fn normalize(line: &str) -> Normalized {
if let Some(n) = parse_rfc5424(line) {
return n;
}
if let Some(n) = parse_rfc3164(line) {
return n;
}
Normalized {
header: None,
message: line.to_string(),
meta: Default::default(),
}
}
fn parse_rfc5424(input: &str) -> Option<Normalized> {
let bytes = input.as_bytes();
if bytes.is_empty() || bytes[0] != b'<' {
return None;
}
let mut i = 1usize;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b'>' {
return None;
}
let pri_str = &input[1..i];
let mut j = i + 1;
if j >= bytes.len() || !bytes[j].is_ascii_digit() {
return None;
}
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j >= bytes.len() || bytes[j] != b' ' {
return None;
}
j += 1; let mut tok = 0;
while j < bytes.len() && tok < 5 {
while j < bytes.len() && bytes[j] != b' ' {
j += 1;
}
if j >= bytes.len() {
return None;
}
j += 1;
tok += 1;
}
if tok != 5 || j > bytes.len() {
return None;
}
if j < bytes.len() && bytes[j] == b'-' {
j += 1;
if j < bytes.len() && bytes[j] == b' ' {
j += 1;
}
let msg = input[j..].to_string();
let header = input[..j].trim_end().to_string();
let meta = parse_pri_from_header(&format!("<{}>", pri_str));
return Some(Normalized {
header: Some(header),
message: msg,
meta,
});
}
if j < bytes.len() && bytes[j] == b'[' {
let rest = &input[j + 1..];
if let Some(close) = rest.find(']') {
j = j + 1 + close + 1; if j < bytes.len() && bytes[j] == b' ' {
j += 1;
}
let msg = input[j..].to_string();
let header = input[..j].trim_end().to_string();
let meta = parse_pri_from_header(&format!("<{}>", pri_str));
return Some(Normalized {
header: Some(header),
message: msg,
meta,
});
}
}
None
}
fn parse_rfc5424_slice(input: &str) -> Option<NormalizedSlice> {
let bytes = input.as_bytes();
if bytes.is_empty() || bytes[0] != b'<' {
return None;
}
let mut i = 1usize;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b'>' {
return None;
}
let pri_str = &input[1..i];
let mut j = i + 1;
if j >= bytes.len() || !bytes[j].is_ascii_digit() {
return None;
}
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j >= bytes.len() || bytes[j] != b' ' {
return None;
}
j += 1;
let mut tok = 0;
while j < bytes.len() && tok < 5 {
while j < bytes.len() && bytes[j] != b' ' {
j += 1;
}
if j >= bytes.len() {
return None;
}
j += 1;
tok += 1;
}
if tok != 5 || j > bytes.len() {
return None;
}
if j < bytes.len() && bytes[j] == b'-' {
j += 1;
if j < bytes.len() && bytes[j] == b' ' {
j += 1;
}
let meta = parse_pri_from_header(&format!("<{}>", pri_str));
return Some(NormalizedSlice {
msg_start: j,
msg_end: input.len(),
meta,
});
}
if j < bytes.len() && bytes[j] == b'[' {
let rest = &input[j + 1..];
if let Some(close) = rest.find(']') {
j = j + 1 + close + 1; if j < bytes.len() && bytes[j] == b' ' {
j += 1;
}
let meta = parse_pri_from_header(&format!("<{}>", pri_str));
return Some(NormalizedSlice {
msg_start: j,
msg_end: input.len(),
meta,
});
}
}
None
}
fn parse_rfc3164(input: &str) -> Option<Normalized> {
let bytes = input.as_bytes();
if bytes.is_empty() || bytes[0] != b'<' {
return None;
}
let mut i = 1usize;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b'>' {
return None;
}
let pri_str = &input[1..i];
i += 1;
if i + 3 >= bytes.len() {
return None;
}
let month = &input[i..i + 3];
if !is_valid_month(month) {
return None;
}
i += 3;
if i >= bytes.len() || bytes[i] != b' ' {
return None;
}
i += 1;
if i < bytes.len() && bytes[i] == b' ' {
i += 1;
}
if i >= bytes.len() || !bytes[i].is_ascii_digit() {
return None;
}
i += 1;
if i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b' ' {
return None;
}
i += 1;
if i + 8 > bytes.len() {
return None;
}
if !bytes[i].is_ascii_digit()
|| !bytes[i + 1].is_ascii_digit()
|| bytes[i + 2] != b':'
|| !bytes[i + 3].is_ascii_digit()
|| !bytes[i + 4].is_ascii_digit()
|| bytes[i + 5] != b':'
|| !bytes[i + 6].is_ascii_digit()
|| !bytes[i + 7].is_ascii_digit()
{
return None;
}
i += 8;
if i >= bytes.len() || bytes[i] != b' ' {
return None;
}
i += 1;
let hostname_start = i;
while i < bytes.len() && bytes[i] != b' ' {
i += 1;
}
if i == hostname_start || i >= bytes.len() {
return None;
}
i += 1;
if let Some(col) = input[i..].find(": ") {
let msg_start = i + col + 2;
let msg = input[msg_start..].to_string();
let header = input[..msg_start].trim_end().to_string();
let meta = parse_pri_from_header(&format!("<{}>", pri_str));
return Some(Normalized {
header: Some(header),
message: msg,
meta,
});
}
None
}
fn parse_rfc3164_slice(input: &str) -> Option<NormalizedSlice> {
let bytes = input.as_bytes();
if bytes.is_empty() || bytes[0] != b'<' {
return None;
}
let mut i = 1usize;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b'>' {
return None;
}
let pri_str = &input[1..i];
i += 1;
if i + 3 >= bytes.len() {
return None;
}
let month = &input[i..i + 3];
if !is_valid_month(month) {
return None;
}
i += 3;
if i >= bytes.len() || bytes[i] != b' ' {
return None;
}
i += 1;
if i < bytes.len() && bytes[i] == b' ' {
i += 1;
}
if i >= bytes.len() || !bytes[i].is_ascii_digit() {
return None;
}
i += 1;
if i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b' ' {
return None;
}
i += 1;
if i + 8 > bytes.len() {
return None;
}
if !bytes[i].is_ascii_digit()
|| !bytes[i + 1].is_ascii_digit()
|| bytes[i + 2] != b':'
|| !bytes[i + 3].is_ascii_digit()
|| !bytes[i + 4].is_ascii_digit()
|| bytes[i + 5] != b':'
|| !bytes[i + 6].is_ascii_digit()
|| !bytes[i + 7].is_ascii_digit()
{
return None;
}
i += 8;
if i >= bytes.len() || bytes[i] != b' ' {
return None;
}
i += 1;
let hostname_start = i;
while i < bytes.len() && bytes[i] != b' ' {
i += 1;
}
if i == hostname_start || i >= bytes.len() {
return None;
}
i += 1;
if let Some(col) = input[i..].find(": ") {
let msg_start = i + col + 2;
let meta = parse_pri_from_header(&format!("<{}>", pri_str));
return Some(NormalizedSlice {
msg_start,
msg_end: input.len(),
meta,
});
}
None
}
fn is_valid_month(month: &str) -> bool {
matches!(
month,
"Jan"
| "Feb"
| "Mar"
| "Apr"
| "May"
| "Jun"
| "Jul"
| "Aug"
| "Sep"
| "Oct"
| "Nov"
| "Dec"
)
}
fn parse_pri_from_header(header: &str) -> SyslogMeta {
if let Some(end) = header.find('>')
&& header.starts_with('<')
&& let Ok(pri) = header[1..end].parse::<u16>()
{
let pri_u8 = (pri & 0xFF) as u8;
let facility_code = (pri / 8) as u8;
let severity_code = (pri % 8) as u8;
return SyslogMeta {
pri: Some(pri_u8),
facility: Some(facility_name(facility_code).to_string()),
severity: Some(severity_name(severity_code).to_string()),
};
}
Default::default()
}
fn facility_name(code: u8) -> &'static str {
match code {
0 => "kern",
1 => "user",
2 => "mail",
3 => "daemon",
4 => "auth",
5 => "syslog",
6 => "lpr",
7 => "news",
8 => "uucp",
9 => "clock",
10 => "authpriv",
11 => "ftp",
12 => "ntp",
13 => "audit",
14 => "alert",
15 => "cron",
16 => "local0",
17 => "local1",
18 => "local2",
19 => "local3",
20 => "local4",
21 => "local5",
22 => "local6",
23 => "local7",
_ => "unknown",
}
}
fn severity_name(code: u8) -> &'static str {
match code {
0 => "emerg",
1 => "alert",
2 => "crit",
3 => "err",
4 => "warn",
5 => "notice",
6 => "info",
7 => "debug",
_ => "unknown",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_rfc5424() {
let input = "<14>1 2024-10-05T12:34:56Z host app 123 - - hello world";
let n = normalize(input);
assert_eq!(n.message, "hello world");
assert_eq!(n.meta.pri, Some(14));
assert_eq!(n.meta.facility.as_deref(), Some("user"));
assert_eq!(n.meta.severity.as_deref(), Some("info"));
}
#[test]
fn test_normalize_rfc3164() {
let input = "<34>Oct 11 22:14:15 mymachine su: 'su root' failed";
let n = normalize(input);
assert!(n.message.contains("su root"));
assert_eq!(n.meta.pri, Some(34));
assert_eq!(n.meta.facility.as_deref(), Some("auth"));
assert_eq!(n.meta.severity.as_deref(), Some("crit"));
}
#[test]
fn test_normalize_plaintext() {
let input = "just plaintext";
let n = normalize(input);
assert_eq!(n.message, input);
assert!(n.header.is_none());
assert!(n.meta.pri.is_none());
}
#[test]
fn test_non_standard_format_should_not_strip() {
let input1 = "<11>2025-07-07 09:42:43,132 sentinel - info message";
let ns1 = normalize_slice(input1);
assert_eq!(
ns1.msg_start, 0,
"ISO format should not be parsed as RFC3164"
);
let input2 = "<158>Jul23 17:18:36 skyeye SyslogClient[1]: message";
let ns2 = normalize_slice(input2);
assert_eq!(ns2.msg_start, 0, "Missing space after month should fail");
let input3 = "<34>Xyz 11 22:14:15 mymachine su: test";
let ns3 = normalize_slice(input3);
assert_eq!(ns3.msg_start, 0, "Invalid month should fail");
let input4 = "<34>Oct 11 22-14-15 mymachine su: test";
let ns4 = normalize_slice(input4);
assert_eq!(ns4.msg_start, 0, "Invalid time format should fail");
let input5 = "<158>Jul 23 17:18:36 skyeye SyslogClient[1]: message";
let ns5 = normalize_slice(input5);
assert!(
ns5.msg_start > 0,
"Standard RFC3164 should parse successfully"
);
assert_eq!(&input5[ns5.msg_start..ns5.msg_end], "message");
let input6 = "<34>Oct 1 22:14:15 mymachine su: test message";
let ns6 = normalize_slice(input6);
assert!(
ns6.msg_start > 0,
"RFC3164 with single digit day should parse"
);
assert_eq!(&input6[ns6.msg_start..ns6.msg_end], "test message");
}
}