eggserve-core 0.1.2

Security policy, path confinement, and static-serving primitives for eggserve
Documentation
use super::rejected::PathRejection;

pub fn check_component(component: &str) -> Result<(), PathRejection> {
    if has_windows_drive_prefix(component) {
        return Err(PathRejection::WindowsPrefixDenied);
    }

    if component.contains(':') {
        return Err(PathRejection::WindowsAlternateStreamDenied);
    }

    if is_windows_reserved_name(component) {
        return Err(PathRejection::WindowsReservedNameDenied);
    }

    // Windows trims trailing dots and spaces when resolving names. Reject
    // them so a request cannot alias a different normalized component.
    if component.ends_with('.') || component.ends_with(' ') {
        return Err(PathRejection::WindowsReservedNameDenied);
    }

    Ok(())
}

pub fn has_windows_drive_prefix(component: &str) -> bool {
    let bytes = component.as_bytes();
    if bytes.len() < 2 {
        return false;
    }
    bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}

pub fn is_windows_reserved_name(component: &str) -> bool {
    let base = component.split('.').next().unwrap_or("");
    let name = strip_trailing_dots(base);
    if name.is_empty() {
        return false;
    }
    matches!(
        name.to_ascii_uppercase().as_str(),
        "CON"
            | "PRN"
            | "AUX"
            | "NUL"
            | "COM1"
            | "COM2"
            | "COM3"
            | "COM4"
            | "COM5"
            | "COM6"
            | "COM7"
            | "COM8"
            | "COM9"
            | "LPT1"
            | "LPT2"
            | "LPT3"
            | "LPT4"
            | "LPT5"
            | "LPT6"
            | "LPT7"
            | "LPT8"
            | "LPT9"
    )
}

fn strip_trailing_dots(s: &str) -> &str {
    s.trim_end_matches('.')
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    #[test]
    fn reserved_con() {
        assert!(is_windows_reserved_name("CON"));
        assert!(is_windows_reserved_name("con"));
        assert!(is_windows_reserved_name("Con"));
    }

    #[test]
    fn reserved_nul() {
        assert!(is_windows_reserved_name("NUL"));
        assert!(is_windows_reserved_name("nul"));
    }

    #[test]
    fn reserved_com1() {
        assert!(is_windows_reserved_name("COM1"));
        assert!(is_windows_reserved_name("com1"));
    }

    #[test]
    fn reserved_lpt1() {
        assert!(is_windows_reserved_name("LPT1"));
        assert!(is_windows_reserved_name("lpt1"));
    }

    #[test]
    fn not_reserved() {
        assert!(!is_windows_reserved_name("foo"));
        assert!(!is_windows_reserved_name("AUX2"));
        assert!(!is_windows_reserved_name("COM0"));
        assert!(!is_windows_reserved_name("LPT0"));
    }

    #[test]
    fn reserved_with_trailing_dots() {
        assert!(is_windows_reserved_name("CON."));
        assert!(is_windows_reserved_name("NUL..."));
    }

    #[test]
    fn reserved_with_extension() {
        assert!(is_windows_reserved_name("AUX.txt"));
        assert!(is_windows_reserved_name("CON.log"));
        assert!(is_windows_reserved_name("nul.bak"));
    }

    #[test]
    fn drive_prefix() {
        assert!(has_windows_drive_prefix("C:"));
        assert!(has_windows_drive_prefix("c:"));
        assert!(has_windows_drive_prefix("Z:/path"));
    }

    #[test]
    fn not_drive_prefix() {
        assert!(!has_windows_drive_prefix(":"));
        assert!(!has_windows_drive_prefix("/C:"));
        assert!(!has_windows_drive_prefix("CC:"));
        assert!(!has_windows_drive_prefix("1:"));
    }

    #[test]
    fn ads_denied() {
        assert_eq!(
            check_component("file.txt:stream").unwrap_err(),
            PathRejection::WindowsAlternateStreamDenied
        );
    }

    #[test]
    fn drive_denied() {
        assert_eq!(
            check_component("C:").unwrap_err(),
            PathRejection::WindowsPrefixDenied
        );
    }

    #[test]
    fn reserved_denied() {
        assert_eq!(
            check_component("CON").unwrap_err(),
            PathRejection::WindowsReservedNameDenied
        );
    }

    #[test]
    fn reserved_with_ext_denied() {
        assert_eq!(
            check_component("AUX.txt").unwrap_err(),
            PathRejection::WindowsReservedNameDenied
        );
    }

    #[test]
    fn ok_component() {
        assert!(check_component("foo").is_ok());
        assert!(check_component("bar.txt").is_ok());
        assert!(check_component("a1").is_ok());
    }

    proptest::proptest! {
        #[test]
        fn has_windows_drive_prefix_never_panics(s in ".*") {
            let _ = has_windows_drive_prefix(&s);
        }

        #[test]
        fn is_windows_reserved_name_never_panics(s in ".*") {
            let _ = is_windows_reserved_name(&s);
        }

        #[test]
        fn check_component_never_panics(s in ".*") {
            let _ = check_component(&s);
        }

        #[test]
        fn reserved_name_is_always_case_insensitive(s in "[A-Za-z]{1,4}") {
            let upper = s.to_uppercase();
            let lower = s.to_lowercase();
            prop_assert_eq!(is_windows_reserved_name(&upper), is_windows_reserved_name(&lower));
        }

        #[test]
        fn drive_prefix_requires_two_bytes(s in ".*") {
            if has_windows_drive_prefix(&s) {
                prop_assert!(s.len() >= 2);
                prop_assert!(s.as_bytes()[0].is_ascii_alphabetic());
                prop_assert_eq!(s.as_bytes()[1], b':');
            }
        }

        #[test]
        fn check_component_drive_takes_precedence(s in "[A-Za-z]:(.*)") {
            if let Err(e) = check_component(&s) {
                prop_assert_eq!(e, PathRejection::WindowsPrefixDenied);
            }
        }

        #[test]
        fn check_component_no_false_positive(s in "[a-zA-Z0-9_-]+") {
            prop_assert!(check_component(&s).is_ok());
        }
    }
}