praxis-proxy-filter 0.7.2

Filter pipeline engine and built-in filters for Praxis
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Praxis Contributors

//! Segment-boundary path prefix matching per Gateway API semantics.
//!
//! A prefix `/api` matches `/api`, `/api/`, and `/api/v1` but NOT `/apikeys`.
//! A trailing slash on the configured prefix is ignored (`/api` ≡ `/api/`).

/// Returns `true` if `path` matches `prefix` at a segment boundary.
///
/// An empty or root-only prefix matches everything. Otherwise the prefix is
/// trimmed of a trailing `/` and the path must either equal the trimmed prefix
/// or continue with a `/` separator.
pub(crate) fn path_prefix_matches(path: &str, prefix: &str) -> bool {
    let trimmed = prefix.strip_suffix('/').unwrap_or(prefix);
    if trimmed.is_empty() {
        return true;
    }
    if path == trimmed {
        return true;
    }
    path.starts_with(trimmed) && path.as_bytes().get(trimmed.len()) == Some(&b'/')
}

/// Returns the specificity (effective length) of a path prefix.
///
/// Trailing `/` is stripped so `/api/` and `/api` have equal specificity.
/// An empty prefix gets specificity 1 (root `/`).
pub(crate) fn path_prefix_specificity(prefix: &str) -> usize {
    let trimmed = prefix.strip_suffix('/').unwrap_or(prefix);
    if trimmed.is_empty() { 1 } else { trimmed.len() }
}

#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(clippy::unwrap_used, reason = "tests")]
mod tests {
    use super::*;

    #[test]
    fn root_matches_everything() {
        assert!(path_prefix_matches("/anything", "/"));
        assert!(path_prefix_matches("/", "/"));
    }

    #[test]
    fn empty_prefix_matches_everything() {
        assert!(path_prefix_matches("/foo", ""));
        assert!(path_prefix_matches("/", ""));
    }

    #[test]
    fn exact_match() {
        assert!(path_prefix_matches("/api", "/api"));
    }

    #[test]
    fn with_trailing_slash_on_path() {
        assert!(path_prefix_matches("/api/", "/api"));
    }

    #[test]
    fn subpath_match() {
        assert!(path_prefix_matches("/api/v1", "/api"));
    }

    #[test]
    fn no_segment_boundary_rejected() {
        assert!(!path_prefix_matches("/apikeys", "/api"));
    }

    #[test]
    fn prefix_with_trailing_slash_equivalent() {
        assert!(path_prefix_matches("/api/v1", "/api/"));
        assert!(path_prefix_matches("/api", "/api/"));
    }

    #[test]
    fn specificity_trims_trailing_slash() {
        assert_eq!(path_prefix_specificity("/api"), path_prefix_specificity("/api/"));
    }

    #[test]
    fn specificity_root() {
        assert_eq!(path_prefix_specificity("/"), 1);
        assert_eq!(path_prefix_specificity(""), 1);
    }

    #[test]
    fn specificity_value() {
        assert_eq!(path_prefix_specificity("/api"), 4);
        assert_eq!(path_prefix_specificity("/api/v1"), 7);
    }

    #[test]
    fn double_slash_prefix() {
        assert!(!path_prefix_matches("/foo", "//"), "/foo must not match // prefix");
        assert!(path_prefix_matches("//bar", "//"), "//bar should match // prefix");
    }

    mod properties {
        use proptest::prelude::*;

        use super::*;

        /// Strategy for a single path segment (no slashes).
        fn segment() -> impl Strategy<Value = String> {
            "[a-z0-9]{1,8}"
        }

        /// Strategy for an absolute path of 1..=4 segments.
        fn path() -> impl Strategy<Value = String> {
            proptest::collection::vec(segment(), 1..=4).prop_map(|segs| format!("/{}", segs.join("/")))
        }

        proptest! {
            #[test]
            fn match_implies_starts_with(p in path(), prefix in path()) {
                if path_prefix_matches(&p, &prefix) {
                    let trimmed = prefix.strip_suffix('/').unwrap_or(&prefix);
                    prop_assert!(p.starts_with(trimmed));
                }
            }

            #[test]
            fn trailing_slash_equivalence(p in path(), prefix in path()) {
                prop_assert_eq!(
                    path_prefix_matches(&p, &prefix),
                    path_prefix_matches(&p, &format!("{prefix}/"))
                );
            }

            #[test]
            fn prefix_matches_own_extensions(prefix in path(), seg in segment()) {
                let extended = format!("{prefix}/{seg}");
                prop_assert!(path_prefix_matches(&prefix, &prefix));
                prop_assert!(path_prefix_matches(&extended, &prefix));
            }

            #[test]
            fn non_boundary_extension_never_matches(prefix in path(), tail in "[a-z0-9]{1,8}") {
                let extended = format!("{prefix}{tail}");
                prop_assert!(!path_prefix_matches(&extended, &prefix));
            }

            #[test]
            fn specificity_monotonic(prefix in path(), seg in segment()) {
                let extended = format!("{prefix}/{seg}");
                prop_assert!(path_prefix_specificity(&extended) > path_prefix_specificity(&prefix));
            }

            #[test]
            fn never_panics(p in "\\PC{0,32}", prefix in "\\PC{0,32}") {
                let _ = path_prefix_matches(&p, &prefix);
                let _ = path_prefix_specificity(&prefix);
            }
        }
    }
}