use super::{LogItem, LogParser};
use anyhow::{anyhow, Result};
use chrono::{DateTime, FixedOffset, NaiveDate};
static CLF_MONTH_MAP: phf::Map<&'static str, u32> = phf::phf_map! {
"Jan" => 1,
"Feb" => 2,
"Mar" => 3,
"Apr" => 4,
"May" => 5,
"Jun" => 6,
"Jul" => 7,
"Aug" => 8,
"Sep" => 9,
"Oct" => 10,
"Nov" => 11,
"Dec" => 12,
};
fn clf_date_parse(s: &str) -> Result<DateTime<FixedOffset>> {
let day: u32 = s[0..2].parse()?;
let month_str = &s[3..6];
let month = *CLF_MONTH_MAP
.get(month_str)
.ok_or_else(|| anyhow!("invalid month"))?;
let year: i32 = s[7..11].parse()?;
let hour: u32 = s[12..14].parse()?;
let minute: u32 = s[15..17].parse()?;
let second: u32 = s[18..20].parse()?;
let timezone_sign = if &s[21..22] == "-" { -1 } else { 1 };
let timezone_hour: i32 = s[22..24].parse().unwrap();
let timezone_offset = timezone_sign * timezone_hour * 3600;
let timezone = FixedOffset::east_opt(timezone_offset).ok_or(anyhow!("invalid timezone"))?;
let datetime = NaiveDate::from_ymd_opt(year, month, day)
.and_then(|date| date.and_hms_opt(hour, minute, second))
.map(|date| date.and_local_timezone(timezone))
.ok_or(anyhow!("invalid datetime"))?;
let datetime = datetime
.single()
.ok_or(anyhow!("ambiguous or invalid datetime"))?;
Ok(datetime)
}
#[derive(Default)]
pub struct CombinedParser {}
impl LogParser for CombinedParser {
fn parse(&self, line: &str) -> Result<LogItem> {
let mut parts = line.splitn(2, " - ");
let client = parts
.next()
.ok_or(anyhow!("No client IP found"))?
.to_string();
let rest = parts
.next()
.ok_or(anyhow!("Unexpected log format after client IP"))?;
let time_start = rest
.find('[')
.ok_or(anyhow!("No opening bracket for time found"))?
+ 1;
let time_end = rest
.find(']')
.ok_or(anyhow!("No closing bracket for time found"))?;
let time_str = &rest[time_start..time_end];
let time = clf_date_parse(time_str)?;
let request_start = rest
.find('"')
.ok_or(anyhow!("No opening quote for request found"))?
+ 1;
let request_end = rest[request_start..]
.find('"')
.ok_or(anyhow!("No closing quote for request found"))?
+ request_start;
let request_str = &rest[request_start..request_end];
let url = request_str
.split_whitespace()
.nth(1)
.ok_or(anyhow!("No URL found in request"))?
.to_string();
let rest = &rest[request_end + 2..];
let status_end = rest.find(' ').ok_or(anyhow!("No space after URL found"))?;
let status_str = &rest[..status_end];
let status: u16 = status_str.parse()?;
let size_start = status_end + 1;
let size_end = rest[size_start..]
.find(' ')
.ok_or(anyhow!("No space after status found"))?
+ size_start;
let size_str = &rest[size_start..size_end];
let size: u64 = size_str.parse()?;
let rest = &rest[size_end + 1..];
let matches = rest.match_indices('"').collect::<Vec<_>>();
let user_agent_start = matches[2].0 + 1;
let user_agent_end = matches[3].0;
let user_agent = &rest[user_agent_start..user_agent_end];
Ok(LogItem {
client: client.parse()?,
time,
url,
size,
status,
user_agent: user_agent.to_string(),
proxied: false, })
}
}
#[cfg(test)]
mod tests {
use std::{net::IpAddr, str::FromStr};
use super::*;
use test_log::test;
#[test]
fn test_clf_date_parse() {
let datetime = clf_date_parse("01/Jan/2021:00:00:00 +0000").unwrap();
assert_eq!(datetime.to_rfc3339(), "2021-01-01T00:00:00+00:00");
}
#[test]
fn test_combined_parse() {
let parser = CombinedParser::default();
let log = r#"123.45.67.8 - - [12/Mar/2023:00:15:32 +0800] "GET /path/to/a/file HTTP/1.1" 200 3009 "-" """#;
let item = parser.parse(log).unwrap();
assert_eq!(item.client, IpAddr::from_str("123.45.67.8").unwrap());
assert_eq!(item.url, "/path/to/a/file");
assert_eq!(item.size, 3009);
assert_eq!(item.status, 200);
assert_eq!(item.time.to_rfc3339(), "2023-03-12T00:15:32+08:00");
assert_eq!(item.user_agent, "");
}
}