ppoppo-sdk-core 0.4.1

Internal shared primitives for the Ppoppo SDK family (pas-external, pas-plims, pcs-external) — verifier port, audit trait, session liveness port, OIDC discovery, perimeter Bearer-auth Layer kit, identity types. Not a stable public API; do not depend on this crate directly. Consume the SDK crates that re-export from it (e.g. `pas-external`).
Documentation
//! Path-prefix-aware tonic channel for GKE Ingress routing.
//!
//! [`PathPrefixChannel`] wraps `tonic::transport::Channel` and prepends a path
//! prefix (e.g. `/plims`, `/ext`) to every outgoing gRPC method path, enabling
//! path-based Ingress rules on GKE (`accounts.ppoppo.com/plims` → the `:3103`
//! ops face; `api.ppoppo.com/ext` → the PCS External API). When the API URL
//! has no path component (e.g. `http://localhost:3103`), requests pass through
//! unchanged.
//!
//! Shared by the Ppoppo gRPC SDK family (`pas-plims`, `pcs-external`);
//! consumers interact through each SDK's client, never this channel directly.

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;

use tonic::transport::Channel;

use super::error::Error;

/// A validation-only gRPC method path used at connect time to check that a
/// non-empty prefix concatenates into a parseable `PathAndQuery`. The service
/// name is irrelevant — only the shape `{prefix}/{service}/{method}` matters,
/// so a single fixed probe covers every SDK.
const PREFIX_PROBE_PATH: &str = "/ppoppo.sdk.v1.PrefixProbe/Method";

/// Deterministically-invalid path substituted if the per-call prepend somehow
/// fails (the prefix is pre-validated at `connect`, so this is unreachable in
/// practice). Forces a server 404 rather than a silent unprefixed route to the
/// wrong backend.
const INVALID_PATH_SENTINEL: &str = "/__ppoppo_sdk_invalid_path__";

/// A gRPC channel that prepends an optional path prefix to all requests.
#[derive(Clone, Debug)]
pub struct PathPrefixChannel {
    inner: Channel,
    prefix: String,
}

impl PathPrefixChannel {
    /// Connect to `api_url` and extract the optional path prefix.
    ///
    /// Default timeouts: 10 s connect, 30 s request.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Transport`] on DNS/TLS/connect failures.
    /// Returns [`Error::InvalidPathPrefix`] if the path component of the URL
    /// cannot be prepended to a gRPC method path.
    pub async fn connect(api_url: &str) -> Result<Self, Error> {
        let (base_url, prefix) = split_prefix(api_url)?;

        let endpoint = tonic::transport::Endpoint::from_shared(base_url.clone())
            .map_err(|e| Error::Transport(format!("invalid endpoint '{base_url}': {e}")))?
            .connect_timeout(Duration::from_secs(10))
            .timeout(Duration::from_secs(30));

        let endpoint = if api_url.starts_with("https://") {
            endpoint
                .tls_config(tonic::transport::ClientTlsConfig::new().with_enabled_roots())
                .map_err(|e| Error::Transport(format!("TLS configuration failed: {e}")))?
        } else {
            endpoint
        };

        let channel = endpoint
            .connect()
            .await
            .map_err(|e| Error::Transport(format!("failed to connect to '{base_url}': {e}")))?;

        Ok(Self { inner: channel, prefix })
    }
}

impl tower_service::Service<http::Request<tonic::body::Body>> for PathPrefixChannel {
    type Response = http::Response<tonic::body::Body>;
    type Error = tonic::transport::Error;
    type Future =
        Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        tower_service::Service::poll_ready(&mut self.inner, cx)
    }

    fn call(&mut self, mut req: http::Request<tonic::body::Body>) -> Self::Future {
        if !self.prefix.is_empty() && !prepend_path_prefix(&mut req, &self.prefix) {
            // Pre-validated at connect(). If we somehow get here, send a
            // known-bad path so the server returns a deterministic 404 rather
            // than silently routing to the wrong backend.
            *req.uri_mut() = INVALID_PATH_SENTINEL.parse().unwrap_or_else(|e| {
                // Unreachable: INVALID_PATH_SENTINEL is a const, test-locked to
                // parse (`probe_constant_forms_a_valid_path_and_query`). Make
                // the impossible LOUD rather than silently reverting to the
                // unprefixed URI — the latter would route to the wrong backend.
                tracing::error!(
                    sentinel = INVALID_PATH_SENTINEL,
                    error = %e,
                    "INVALID_PATH_SENTINEL failed to parse; request keeps its \
                     unprefixed URI and may reach the wrong backend"
                );
                req.uri().clone()
            });
        }
        let fut = tower_service::Service::call(&mut self.inner, req);
        Box::pin(fut)
    }
}

/// Split an API URL into `(base_url, path_prefix)` for GKE-Ingress routing.
///
/// Pure — no IO. Extracted from [`PathPrefixChannel::connect`] so the
/// prefix / scheme / authority derivation is table-testable without opening a
/// socket. `base_url` is `{scheme}://{authority}` when the URL carries a path
/// prefix (tonic connects to the origin; the per-call rewrite prepends the
/// prefix), or the URL verbatim when there is no prefix. `prefix` is the path
/// minus any trailing slash, or empty for a bare origin.
///
/// The scheme is taken from the URL, never assumed: `http://…/plims` yields an
/// `http://` base_url (not rebuilt as `https://`), which — paired with
/// `connect`'s `starts_with("https://")` TLS gate — keeps a dev plaintext
/// endpoint plaintext.
///
/// # Errors
///
/// - [`Error::Transport`] — the URL is unparseable, or carries a path prefix
///   but no authority (no origin to connect to).
/// - [`Error::InvalidPathPrefix`] — the path cannot be prepended to a gRPC
///   method path (fails the `{prefix}{PREFIX_PROBE_PATH}` probe).
fn split_prefix(api_url: &str) -> Result<(String, String), Error> {
    let uri: http::Uri = api_url
        .parse()
        .map_err(|e| Error::Transport(format!("invalid API URL '{api_url}': {e}")))?;

    let raw_path = uri.path().trim_end_matches('/');
    let prefix = if raw_path.is_empty() || raw_path == "/" {
        String::new()
    } else {
        raw_path.to_string()
    };

    // Pre-validate the prefix so `Service::call` never silently falls back to
    // an unprefixed URI.
    if !prefix.is_empty() {
        let probe = format!("{prefix}{PREFIX_PROBE_PATH}");
        probe
            .parse::<http::uri::PathAndQuery>()
            .map_err(|e| Error::InvalidPathPrefix {
                prefix: prefix.clone(),
                reason: e.to_string(),
            })?;
    }

    let base_url = if prefix.is_empty() {
        api_url.to_string()
    } else {
        let scheme = uri.scheme_str().unwrap_or("https");
        let authority = uri
            .authority()
            .map(|a| a.as_str())
            .ok_or_else(|| Error::Transport(format!("missing authority in URL: {api_url}")))?;
        format!("{scheme}://{authority}")
    };

    Ok((base_url, prefix))
}

// Generic over the body type — the rewrite only touches the URI, so this is
// testable with a unit body (`()`), not just `tonic::body::Body`.
fn prepend_path_prefix<B>(req: &mut http::Request<B>, prefix: &str) -> bool {
    let pq_str = req
        .uri()
        .path_and_query()
        .map(|pq| pq.as_str())
        .unwrap_or("/");
    let new_path = format!("{prefix}{pq_str}");
    let Ok(new_pq) = new_path.parse::<http::uri::PathAndQuery>() else {
        return false;
    };
    let mut parts = req.uri().clone().into_parts();
    parts.path_and_query = Some(new_pq);
    let Ok(new_uri) = http::Uri::from_parts(parts) else {
        return false;
    };
    *req.uri_mut() = new_uri;
    true
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
    use super::*;

    fn uri_after_prefix(path: &str, prefix: &str) -> Option<String> {
        let mut req = http::Request::builder().uri(path).body(()).ok()?;
        prepend_path_prefix(&mut req, prefix).then(|| req.uri().to_string())
    }

    #[test]
    fn prepends_prefix_to_grpc_method_path() {
        assert_eq!(
            uri_after_prefix("/pas.plims.v1.NumberService/Create", "/plims").as_deref(),
            Some("/plims/pas.plims.v1.NumberService/Create"),
        );
    }

    #[test]
    fn preserves_query_string() {
        assert_eq!(
            uri_after_prefix("/chat.external.Svc/Method?page=2", "/ext").as_deref(),
            Some("/ext/chat.external.Svc/Method?page=2"),
        );
    }

    #[test]
    fn probe_constant_forms_a_valid_path_and_query() {
        // Locks the connect-time validation contract: PREFIX_PROBE_PATH must be
        // a valid gRPC method-path suffix so `{prefix}{PROBE}` parses for any
        // real prefix. Guards the cosmetic-constant substitution made in the
        // sdk-core hoist.
        let probe = format!("/plims{PREFIX_PROBE_PATH}");
        assert!(probe.parse::<http::uri::PathAndQuery>().is_ok());
        // The sentinel must itself be a parseable, deterministically-invalid path.
        assert!(INVALID_PATH_SENTINEL.parse::<http::Uri>().is_ok());
    }

    #[test]
    fn split_prefix_derives_base_url_and_prefix() {
        // (api_url, expected base_url, expected prefix)
        let cases = [
            // Bare origin, no path prefix → URL passes through verbatim.
            ("http://localhost:3103", "http://localhost:3103", ""),
            // Trailing-slash-only path is "no prefix" → verbatim (slash kept).
            ("http://localhost:3103/", "http://localhost:3103/", ""),
            // Path prefix → base_url collapses to origin, prefix carries path.
            ("http://localhost:3103/plims", "http://localhost:3103", "/plims"),
            // https scheme preserved (the 2026-05-27-class TLS-mismatch guard:
            // http stays http, https stays https — never rebuilt).
            ("https://accounts.ppoppo.com/plims", "https://accounts.ppoppo.com", "/plims"),
            ("https://api.ppoppo.com/ext", "https://api.ppoppo.com", "/ext"),
        ];
        for (url, base, prefix) in cases {
            let (got_base, got_prefix) = split_prefix(url).expect("valid URL");
            assert_eq!(got_base, base, "base_url for {url}");
            assert_eq!(got_prefix, prefix, "prefix for {url}");
        }
    }

    #[test]
    fn split_prefix_rejects_path_prefix_without_authority() {
        // A path-only URL has a prefix but no origin to connect to — reject
        // loudly rather than build a schemeless/authority-less endpoint.
        match split_prefix("/plims") {
            Err(Error::Transport(msg)) => assert!(msg.contains("missing authority"), "{msg}"),
            other => panic!("expected Transport(missing authority), got {other:?}"),
        }
    }
}