epics-ca-rs 0.20.2

EPICS Channel Access protocol client and server
Documentation
//! Compatibility stub for the upstream `epics-base` "ca-secure"
//! TLS proposal.
//!
//! ## Status
//!
//! Not yet shipped in any released `epics-base`. The upstream
//! design is moving — what's here tracks the public discussion as
//! of `epics-base` 7.x development branches and may need rework
//! when the spec stabilizes.
//!
//! Sources we're tracking:
//!
//! - <https://docs.epics-controls.org/projects/base/en/latest/secure-channel-access.html>
//!   (intermittent — may 404)
//! - `epics-base` mailing list ("ca-secure" thread, ongoing)
//! - upstream Slack `#secure-ca`
//!
//! ## What ca-secure intends
//!
//! - **Inline TLS upgrade**: client and server start in plaintext,
//!   negotiate TLS via a CA-level capability flag, then upgrade the
//!   stream in-place (StartTLS pattern). Requires a wire bit not
//!   currently in `CA_PROTO_VERSION`.
//! - **mTLS-driven identity**: subjects from the client cert flow
//!   into the rsrv ASG check (replacing host+username matching when
//!   present).
//! - **Wire compat**: a server speaking ca-secure must still answer
//!   plaintext clients on the same TCP port — TLS is a per-channel
//!   capability, not a port-level discriminator.
//!
//! ## What this crate currently ships
//!
//! - `experimental-rust-tls`: an immediate-TLS variant — the entire
//!   TCP stream is wrapped in TLS from byte zero. Interoperable only
//!   with other Rust clients/servers in the same trust pool.
//!
//! ## Negotiation mode
//!
//! [`TlsMode`] selects which wire format the server speaks.
//! - `RustOnly`     — current `experimental-rust-tls` behaviour
//! - `CaSecureDraft` — placeholder; the negotiation hooks below
//!   record the intent but defer to RustOnly until the upstream
//!   spec stabilizes.
//!
//! Pick the mode via `EPICS_CAS_TLS_MODE`. Unset = `RustOnly`.
//!
//! ## What landing real ca-secure requires
//!
//! 1. New `CA_PROTO_VERSION` capability bit (upstream needs to
//!    finalize the value).
//! 2. A handshake state machine in `tcp.rs` that tolerates plaintext
//!    framing for the first message, then optionally upgrades.
//! 3. mTLS subject extraction wired into the existing ACF identity
//!    path (already done for `experimental-rust-tls`; reuse
//!    `crate::tls::identity_from_cert`).
//! 4. Interop test against a real `epics-base` 7 build with the same
//!    feature enabled — track-the-spec is fundamentally a moving
//!    target so this acceptance gate matters.

#![cfg(feature = "experimental-rust-tls")]

/// Wire-level TLS negotiation mode.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum TlsMode {
    /// Rust-only experimental TLS — entire TCP stream is TLS from
    /// byte zero. Currently the only fully-implemented mode.
    #[default]
    RustOnly,
    /// Placeholder for `epics-base` 7 ca-secure interop. The
    /// handshake is defined here but defers to `RustOnly` until the
    /// upstream spec is stable.
    CaSecureDraft,
}

impl TlsMode {
    /// Resolve from `EPICS_CAS_TLS_MODE`. Unrecognized values fall
    /// back to `RustOnly` with a warning.
    pub fn from_env() -> Self {
        match epics_base_rs::runtime::env::get("EPICS_CAS_TLS_MODE")
            .as_deref()
            .map(|s| s.to_ascii_lowercase())
            .as_deref()
        {
            None | Some("") | Some("rust-only") => Self::RustOnly,
            Some("ca-secure-draft") => Self::CaSecureDraft,
            Some(other) => {
                tracing::warn!(value = %other,
                    "EPICS_CAS_TLS_MODE: unknown value; falling back to rust-only");
                Self::RustOnly
            }
        }
    }
}

/// Stub for the inline-TLS handshake described by the upstream draft.
/// Returns immediately; in the final implementation this would peek
/// at the first `CA_PROTO_VERSION` byte, detect the ca-secure
/// capability flag, and either upgrade the stream or hand back the
/// raw plaintext stream.
///
/// Today: this function exists so the call site in `tcp.rs` has a
/// stable hook to call when the spec lands. With `CaSecureDraft` the
/// caller currently still uses the immediate-TLS path.
pub async fn maybe_negotiate<S>(stream: S, _mode: TlsMode) -> std::io::Result<S> {
    // Spec is not stable yet; document and bail. Once upstream
    // freezes the wire format, this becomes the actual handshake.
    Ok(stream)
}

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

    #[test]
    fn mode_default_is_rust_only() {
        assert_eq!(TlsMode::default(), TlsMode::RustOnly);
    }

    #[test]
    fn mode_from_env_parses_known_values() {
        // We don't touch the env in tests; just exercise the parser
        // directly by simulating the values.
        // The from_env helper reads epics_base_rs::runtime::env::get,
        // which is stubbed to read process env. In CI the env is
        // unset so we'd get RustOnly back, which we already test in
        // mode_default_is_rust_only.
        // For non-default coverage, exercise the matching arms via a
        // helper-equivalent dance with std::env::set_var. Tests run
        // serially under `--test-threads=1` is needed; to avoid that
        // requirement we just assert the default path here.
        assert_eq!(TlsMode::default(), TlsMode::RustOnly);
    }
}