rmux-server 0.7.1

Tokio daemon and request dispatcher for the RMUX terminal multiplexer.
Documentation
use rmux_proto::{CreateWebShareRequest, RmuxError};

use super::record::WebShareConnectRole;
use super::secrets::{random_pairing_code, secret_eq};

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct WebSharePairingCodes {
    operator: Option<String>,
    spectator: Option<String>,
}

impl WebSharePairingCodes {
    pub(super) fn for_request(request: &CreateWebShareRequest) -> Result<Self, RmuxError> {
        validate_forced_pin(request.operator_pin.as_deref(), "--pin-operator")?;
        validate_forced_pin(request.spectator_pin.as_deref(), "--pin-spectator")?;

        if !request.require_pin {
            if request.operator_pin.is_some() || request.spectator_pin.is_some() {
                return Err(RmuxError::Server(
                    "web-share forced PINs cannot be used with --no-pin".to_owned(),
                ));
            }
            return Ok(Self::default());
        }

        let operator = role_pin(request.operator, request.operator_pin.as_deref(), None)?;
        let spectator = role_pin(
            request.spectator,
            request.spectator_pin.as_deref(),
            operator.as_deref(),
        )?;

        if operator.is_some() && operator == spectator {
            return Err(RmuxError::Server(
                "web-share operator and spectator PINs must differ".to_owned(),
            ));
        }

        Ok(Self {
            operator,
            spectator,
        })
    }

    pub(super) fn operator(&self) -> Option<&str> {
        self.operator.as_deref()
    }

    pub(super) fn spectator(&self) -> Option<&str> {
        self.spectator.as_deref()
    }

    pub(super) fn check(
        &self,
        pin: Option<&str>,
        role: WebShareConnectRole,
    ) -> Result<(), RmuxError> {
        let expected = match role {
            WebShareConnectRole::Operator => self.operator(),
            WebShareConnectRole::Spectator => self.spectator(),
        };
        let Some(expected) = expected else {
            return Ok(());
        };
        if pin.is_some_and(|provided| secret_eq(provided, expected)) {
            return Ok(());
        }
        let message = if pin.is_some() {
            "invalid web-share pairing code"
        } else {
            "missing web-share pairing code"
        };
        Err(RmuxError::Server(message.to_owned()))
    }
}

fn role_pin(
    enabled: bool,
    forced: Option<&str>,
    avoid: Option<&str>,
) -> Result<Option<String>, RmuxError> {
    if !enabled {
        if forced.is_some() {
            return Err(RmuxError::Server(
                "web-share forced PIN supplied for a disabled role".to_owned(),
            ));
        }
        return Ok(None);
    }
    if let Some(pin) = forced {
        return Ok(Some(pin.to_owned()));
    }
    loop {
        let generated = random_pairing_code()?;
        if avoid != Some(generated.as_str()) {
            return Ok(Some(generated));
        }
    }
}

fn validate_forced_pin(pin: Option<&str>, flag: &str) -> Result<(), RmuxError> {
    let Some(pin) = pin else {
        return Ok(());
    };
    if pin.len() == 6 && pin.bytes().all(|byte| byte.is_ascii_digit()) {
        return Ok(());
    }
    Err(RmuxError::Server(format!(
        "web-share {flag} must be exactly 6 ASCII digits"
    )))
}

#[cfg(test)]
mod tests {
    use rmux_proto::{CreateWebShareRequest, WebShareScope};

    use super::WebSharePairingCodes;

    fn request() -> CreateWebShareRequest {
        CreateWebShareRequest {
            scope: WebShareScope::Session("demo".parse().expect("session name")),
            public_base_url: None,
            tunnel_provider: None,
            frontend_url: None,
            ttl_seconds: None,
            expires_at_unix: None,
            max_spectators: None,
            max_operators: None,
            url_options: Default::default(),
            require_pin: true,
            operator_pin: None,
            spectator_pin: None,
            terminal_palette: None,
            operator: true,
            spectator: true,
            controls: true,
            kill_session_on_expire: false,
        }
    }

    #[test]
    fn generated_role_pins_are_distinct() {
        let codes = WebSharePairingCodes::for_request(&request()).expect("pairing codes");

        assert_ne!(codes.operator(), codes.spectator());
    }

    #[test]
    fn forced_role_pins_are_kept() {
        let mut request = request();
        request.operator_pin = Some("123456".to_owned());
        request.spectator_pin = Some("654321".to_owned());

        let codes = WebSharePairingCodes::for_request(&request).expect("pairing codes");

        assert_eq!(codes.operator(), Some("123456"));
        assert_eq!(codes.spectator(), Some("654321"));
    }

    #[test]
    fn matching_forced_role_pins_are_rejected() {
        let mut request = request();
        request.operator_pin = Some("123456".to_owned());
        request.spectator_pin = Some("123456".to_owned());

        assert!(WebSharePairingCodes::for_request(&request).is_err());
    }

    #[test]
    fn forced_pin_requires_six_digits() {
        let mut request = request();
        request.operator_pin = Some("abc123".to_owned());

        assert!(WebSharePairingCodes::for_request(&request).is_err());
    }
}