eggserve-core 0.1.1

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

pub fn validate_components(
    components: &[String],
    policy: &PathPolicy,
) -> Result<(), PathRejection> {
    for component in components {
        if component.contains('\0') {
            return Err(PathRejection::NulByte);
        }

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

        if component == "." {
            return Err(PathRejection::CurrentComponent);
        }

        if component == ".." {
            return Err(PathRejection::ParentComponent);
        }

        if policy.reject_backslash && component.contains('\\') {
            return Err(PathRejection::SeparatorAmbiguity);
        }

        if policy.dotfiles == super::policy::DotfilePolicy::Denied && component.starts_with('.') {
            return Err(PathRejection::DotfileDenied);
        }

        if let Ok(decoded) = decode::percent_decode(component) {
            if decoded == "." {
                return Err(PathRejection::CurrentComponent);
            }
            if decoded == ".." {
                return Err(PathRejection::ParentComponent);
            }
        }

        platform::check_component(component)?;
    }

    Ok(())
}

pub fn split_components(path: &str) -> Vec<String> {
    path.split('/')
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string())
        .collect()
}

pub fn normalize_path(path: &str) -> String {
    if path == "/" {
        return String::new();
    }

    let stripped = path.trim_start_matches('/');

    let mut normalized = String::with_capacity(stripped.len());
    let mut prev_was_slash = false;

    for c in stripped.chars() {
        if c == '/' {
            if !prev_was_slash {
                normalized.push('/');
            }
            prev_was_slash = true;
        } else {
            normalized.push(c);
            prev_was_slash = false;
        }
    }

    normalized
}

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

    #[test]
    fn split_simple() {
        assert_eq!(
            split_components("/foo/bar"),
            vec!["foo".to_string(), "bar".to_string()]
        );
    }

    #[test]
    fn split_empty() {
        assert!(split_components("/").is_empty());
    }

    #[test]
    fn split_multiple_slashes() {
        assert_eq!(
            split_components("/foo//bar///baz"),
            vec!["foo".to_string(), "bar".to_string(), "baz".to_string()]
        );
    }

    #[test]
    fn normalize_consecutive() {
        assert_eq!(normalize_path("/foo//bar"), "foo/bar");
        assert_eq!(normalize_path("/foo///bar"), "foo/bar");
        assert_eq!(normalize_path("//foo"), "foo");
        assert_eq!(normalize_path("/"), "");
        assert_eq!(normalize_path("/foo"), "foo");
    }

    #[test]
    fn reject_empty_component() {
        let comps = vec!["foo".to_string(), "".to_string(), "bar".to_string()];
        let policy = PathPolicy::default();
        assert!(validate_components(&comps, &policy).is_ok());
    }

    #[test]
    fn reject_dot() {
        let comps = vec!["foo".to_string(), ".".to_string(), "bar".to_string()];
        let policy = PathPolicy::default();
        assert_eq!(
            validate_components(&comps, &policy).unwrap_err(),
            PathRejection::CurrentComponent
        );
    }

    #[test]
    fn reject_dotdot() {
        let comps = vec!["foo".to_string(), "..".to_string(), "bar".to_string()];
        let policy = PathPolicy::default();
        assert_eq!(
            validate_components(&comps, &policy).unwrap_err(),
            PathRejection::ParentComponent
        );
    }

    #[test]
    fn reject_nul() {
        let comps = vec!["foo\0bar".to_string()];
        let policy = PathPolicy::default();
        assert_eq!(
            validate_components(&comps, &policy).unwrap_err(),
            PathRejection::NulByte
        );
    }

    #[test]
    fn reject_slash_in_component() {
        let comps = vec!["foo/bar".to_string()];
        let policy = PathPolicy::default();
        assert_eq!(
            validate_components(&comps, &policy).unwrap_err(),
            PathRejection::SeparatorAmbiguity
        );
    }

    #[test]
    fn reject_backslash() {
        let comps = vec!["foo\\bar".to_string()];
        let policy = PathPolicy::default();
        assert_eq!(
            validate_components(&comps, &policy).unwrap_err(),
            PathRejection::SeparatorAmbiguity
        );
    }

    #[test]
    fn allow_backslash_when_policy_permits() {
        let comps = vec!["foo\\bar".to_string()];
        let policy = PathPolicy {
            reject_backslash: false,
            ..PathPolicy::default()
        };
        assert!(validate_components(&comps, &policy).is_ok());
    }

    #[test]
    fn reject_dotfile() {
        let comps = vec![".env".to_string()];
        let policy = PathPolicy::default();
        assert_eq!(
            validate_components(&comps, &policy).unwrap_err(),
            PathRejection::DotfileDenied
        );
    }

    #[test]
    fn allow_dotfile_when_policy_permits() {
        let comps = vec![".env".to_string()];
        let policy = PathPolicy {
            dotfiles: crate::path::DotfilePolicy::Allow,
            ..PathPolicy::default()
        };
        assert!(validate_components(&comps, &policy).is_ok());
    }

    #[test]
    fn reject_windows_drive_in_component() {
        let comps = vec!["C:".to_string()];
        let policy = PathPolicy::default();
        assert_eq!(
            validate_components(&comps, &policy).unwrap_err(),
            PathRejection::WindowsPrefixDenied
        );
    }

    #[test]
    fn reject_windows_reserved_in_component() {
        let comps = vec!["CON".to_string()];
        let policy = PathPolicy::default();
        assert_eq!(
            validate_components(&comps, &policy).unwrap_err(),
            PathRejection::WindowsReservedNameDenied
        );
    }

    #[test]
    fn reject_windows_ads_in_component() {
        let comps = vec!["file.txt:stream".to_string()];
        let policy = PathPolicy::default();
        assert_eq!(
            validate_components(&comps, &policy).unwrap_err(),
            PathRejection::WindowsAlternateStreamDenied
        );
    }

    #[test]
    fn ok_components() {
        let comps = vec!["foo".to_string(), "bar.txt".to_string(), "a1".to_string()];
        let policy = PathPolicy::default();
        assert!(validate_components(&comps, &policy).is_ok());
    }

    #[test]
    fn reject_double_encoded_dotdot() {
        let comps = vec!["%2e%2e".to_string()];
        let policy = PathPolicy::default();
        assert_eq!(
            validate_components(&comps, &policy).unwrap_err(),
            PathRejection::ParentComponent
        );
    }

    #[test]
    fn reject_double_encoded_dot() {
        let comps = vec!["%2e".to_string()];
        let policy = PathPolicy::default();
        assert_eq!(
            validate_components(&comps, &policy).unwrap_err(),
            PathRejection::CurrentComponent
        );
    }

    #[test]
    fn reject_double_encoded_uppercase() {
        let comps = vec!["%2E%2E".to_string()];
        let policy = PathPolicy::default();
        assert_eq!(
            validate_components(&comps, &policy).unwrap_err(),
            PathRejection::ParentComponent
        );
    }
}