Functional Weave
Code in TypeScript

monitor.parse-access-log@1.0.0

impl/rust.rs

6,668 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::time_iso_to_unix::iso_to_unix;  ← from time.iso-to-unix ^1.0.0 · built alongside by fune

const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

// Bytes, not chars: every delimiter is ASCII, so slicing at one is always on a
// character boundary, and a multi-byte character never passes a digit check.
fn at(b: &[u8], i: usize) -> u8 {
    if i < b.len() { b[i] } else { 0 }
}

fn digits(b: &[u8], from: usize, count: usize) -> Option<i64> {
    let mut n = 0i64;
    for i in from..from + count {
        let c = at(b, i);
        if !c.is_ascii_digit() {
            return None;
        }
        n = n * 10 + i64::from(c - b'0');
    }
    Some(n)
}

fn find(b: &[u8], needle: &[u8], from: usize) -> Option<usize> {
    if from > b.len() {
        return None;
    }
    b[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
}

fn dash(field: &str) -> Option<String> {
    if field == "-" { None } else { Some(field.to_string()) }
}

/// "[10/Oct/2000:13:55:36 -0700]" starting at `i` to Unix seconds, or None.
fn parse_time(t: &str, i: usize) -> Option<i64> {
    let b = t.as_bytes();
    for (off, ch) in [(0, b'['), (3, b'/'), (7, b'/'), (12, b':'), (15, b':'), (18, b':'), (21, b' '), (27, b']')] {
        if at(b, i + off) != ch {
            return None;
        }
    }
    let day = digits(b, i + 1, 2)?;
    let month = MONTHS.iter().position(|m| m.as_bytes() == &b[i + 4..i + 7])? as i64 + 1;
    let year = digits(b, i + 8, 4)?;
    let hour = digits(b, i + 13, 2)?;
    let minute = digits(b, i + 16, 2)?;
    let second = digits(b, i + 19, 2)?;
    let sign = at(b, i + 22);
    let oh = digits(b, i + 23, 2)?;
    let om = digits(b, i + 25, 2)?;
    if year < 1 || hour > 23 || minute > 59 || second > 59 || oh > 23 || om > 59 {
        return None;
    }
    if sign != b'+' && sign != b'-' {
        return None;
    }
    let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
    let month_days = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][(month - 1) as usize];
    if day < 1 || day > month_days {
        return None;
    }
    // Every field is checked above, so iso_to_unix cannot panic here.
    Some(iso_to_unix(&format!(
        "{}-{:02}-{}T{}{}{}:{}",
        &t[i + 8..i + 12],
        month,
        &t[i + 1..i + 3],
        &t[i + 13..i + 21],
        sign as char,
        &t[i + 23..i + 25],
        &t[i + 25..i + 27]
    )))
}

/// The index of the quote closing a field that opens at `i`, honouring \" escapes.
fn closing_quote(b: &[u8], i: usize) -> Option<usize> {
    if at(b, i) != b'"' {
        return None;
    }
    let mut j = i + 1;
    while j < b.len() {
        if b[j] == b'\\' {
            j += 1;
        } else if b[j] == b'"' {
            return Some(j);
        }
        j += 1;
    }
    None
}

fn is_method(m: &str) -> bool {
    !m.is_empty() && m.bytes().all(|c| c.is_ascii_uppercase())
}

/// One Common or Combined Log Format line, or None when the line is in
/// neither format: logs hold junk, and a reader should skip it rather than stop.
pub fn parse_access_log(line: &str) -> Option<AccessLogEntry> {
    let t = line.trim_end_matches(|c| c == '\n' || c == '\r');
    let b = t.as_bytes();
    let host_end = find(b, b" ", 0)?;
    if host_end < 1 {
        return None;
    }
    let ident_end = find(b, b" ", host_end + 1)?;
    if ident_end < host_end + 2 {
        return None;
    }
    let user_end = find(b, b" [", ident_end + 1)?;
    if user_end < ident_end + 2 {
        return None;
    }
    let when = parse_time(t, user_end + 1)?;
    let mut i = user_end + 29;
    if at(b, i) != b' ' {
        return None;
    }
    let req_end = closing_quote(b, i + 1)?;
    let request = &t[i + 2..req_end];
    i = req_end + 1;
    // A three-digit status, then the byte count: both are required.
    if at(b, i) != b' ' || at(b, i + 4) != b' ' {
        return None;
    }
    let status = digits(b, i + 1, 3)?;
    if !(100..=599).contains(&status) {
        return None;
    }
    i += 4;
    let bytes_end = find(b, b" ", i + 1).unwrap_or(b.len());
    let bytes_text = &t[i + 1..bytes_end];
    let mut size: Option<i64> = None;
    if bytes_text != "-" {
        if bytes_text.is_empty() || bytes_text.len() > 15 {
            return None;
        }
        size = Some(digits(bytes_text.as_bytes(), 0, bytes_text.len())?);
    }
    let mut referer: Option<String> = None;
    let mut user_agent: Option<String> = None;
    if bytes_end < b.len() {
        let ref_end = closing_quote(b, bytes_end + 1)?;
        if at(b, ref_end + 1) != b' ' {
            return None;
        }
        let ua_end = closing_quote(b, ref_end + 2)?;
        if ua_end + 1 < b.len() && b[ua_end + 1] != b' ' {
            return None;
        }
        referer = dash(&t[bytes_end + 2..ref_end]);
        user_agent = dash(&t[ref_end + 3..ua_end]);
    }
    let parts: Vec<&str> = request.split(' ').collect();
    let (method, path, protocol) = if parts.len() == 3 && is_method(parts[0]) && !parts[1].is_empty() && parts[2].starts_with("HTTP/") {
        (Some(parts[0].to_string()), Some(parts[1].to_string()), Some(parts[2].to_string()))
    } else if parts.len() == 2 && is_method(parts[0]) && !parts[1].is_empty() {
        (Some(parts[0].to_string()), Some(parts[1].to_string()), None)
    } else {
        (None, None, None)
    };
    Some(AccessLogEntry {
        remote_host: t[..host_end].to_string(),
        ident: dash(&t[host_end + 1..ident_end]),
        user: dash(&t[ident_end + 1..user_end]),
        at: when,
        method,
        path,
        protocol,
        status,
        bytes: size,
        referer,
        user_agent,
    })
}

fn opt_str(v: &Option<String>) -> Value {
    match v {
        Some(s) => Value::str(s),
        None => Value::Null,
    }
}

pub fn access_log_entry_to_value(e: &AccessLogEntry) -> Value {
    Value::obj(vec![
        ("remoteHost", Value::str(&e.remote_host)),
        ("ident", opt_str(&e.ident)),
        ("user", opt_str(&e.user)),
        ("at", Value::Int(e.at)),
        ("method", opt_str(&e.method)),
        ("path", opt_str(&e.path)),
        ("protocol", opt_str(&e.protocol)),
        ("status", Value::Int(e.status)),
        ("bytes", match e.bytes {
            Some(n) => Value::Int(n),
            None => Value::Null,
        }),
        ("referer", opt_str(&e.referer)),
        ("userAgent", opt_str(&e.user_agent)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    match parse_access_log(args[0].as_str()) {
        Some(e) => access_log_entry_to_value(&e),
        None => Value::Null,
    }
}