yukina 0.20260810.0

YUKI-based Next-generation Async-cache
Documentation
// parse combined log format
// Ref:
// - https://github.com/taoky/ayano/blob/master/parser/nginx-combined.go
// - https://github.com/taoky/ayano/blob/master/parser/clf-timeparse.go

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();
        // tracing::debug!("client: {:?}", client);

        let rest = parts
            .next()
            .ok_or(anyhow!("Unexpected log format after client IP"))?;

        // Extracting the time
        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];
        // tracing::debug!("time_str: {:?}", time_str);
        let time = clf_date_parse(time_str)?;

        // Extract the URL from the request
        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();
        // tracing::debug!("url: {:?}", url);

        // tracing::debug!("rest: {:?}", &rest[request_end + 2..]);
        // Extracting the size after the second '"'
        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()?;
        // tracing::debug!("status: {:?}", status);

        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()?;
        // tracing::debug!("size: {:?}", size);

        // find third " as user agent start
        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];
        // tracing::debug!("user_agent: {:?}", user_agent);

        Ok(LogItem {
            client: client.parse()?,
            time,
            url,
            size,
            status,
            user_agent: user_agent.to_string(),
            proxied: false, // combined log format does not have proxied info
        })
    }
}

#[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, "");
    }
}