rmux-server 0.5.0

Tokio daemon and request dispatcher for the RMUX terminal multiplexer.
Documentation
use std::collections::BTreeSet;
use std::env;
use std::fs;
use std::path::PathBuf;

use regex::Regex;
use rmux_proto::RmuxError;
use serde::Deserialize;

include!(concat!(env!("OUT_DIR"), "/tunnel_presets.rs"));

const USER_PRESET_ENV: &str = "RMUX_TUNNEL_PRESET_DIR";
const DEFAULT_READY_TIMEOUT_SECS: u64 = 30;
const MAX_READY_TIMEOUT_SECS: u64 = 300;

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct TunnelPreset {
    pub(super) name: String,
    pub(super) program: String,
    #[serde(default)]
    pub(super) args: Vec<String>,
    pub(super) url_pattern: String,
    #[serde(default)]
    pub(super) ready_pattern: Option<String>,
    #[serde(default)]
    pub(super) url_source: UrlSource,
    #[serde(default = "default_ready_timeout_secs")]
    pub(super) ready_timeout_secs: u64,
    #[serde(default)]
    pub(super) install_hint: Option<String>,
}

#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub(super) enum UrlSource {
    #[default]
    Both,
    Stderr,
    Stdout,
}

impl UrlSource {
    pub(super) const fn accepts(self, source: ProcessOutput) -> bool {
        matches!(
            (self, source),
            (Self::Both, _)
                | (Self::Stderr, ProcessOutput::Stderr)
                | (Self::Stdout, ProcessOutput::Stdout)
        )
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ProcessOutput {
    Stderr,
    Stdout,
}

#[derive(Debug, Clone, Copy)]
pub(super) enum PresetSource {
    Embedded,
    File,
}

pub(super) fn load(name: &str) -> Result<TunnelPreset, RmuxError> {
    if !valid_name(name) {
        return Err(RmuxError::Server(
            "web-share tunnel provider names may contain only ASCII letters, digits, '-' and '_'"
                .to_owned(),
        ));
    }
    for path in preset_paths(name) {
        if path.is_file() {
            let content = fs::read_to_string(&path).map_err(|error| {
                RmuxError::Server(format!(
                    "failed to read web-share tunnel preset '{}': {error}",
                    path.display()
                ))
            })?;
            return parse(name, PresetSource::File, &content);
        }
    }
    if let Some((_, content)) = embedded()
        .iter()
        .find(|(preset_name, _)| *preset_name == name)
    {
        return parse(name, PresetSource::Embedded, content);
    }
    Err(RmuxError::Server(no_preset_message(name)))
}

#[cfg(test)]
pub(super) fn embedded() -> &'static [(&'static str, &'static str)] {
    SHIPPED_TUNNEL_PRESETS
}

#[cfg(not(test))]
fn embedded() -> &'static [(&'static str, &'static str)] {
    SHIPPED_TUNNEL_PRESETS
}

pub(super) fn parse(
    expected_name: &str,
    _source: PresetSource,
    content: &str,
) -> Result<TunnelPreset, RmuxError> {
    let preset: TunnelPreset = toml::from_str(content).map_err(|error| {
        RmuxError::Server(format!(
            "failed to parse web-share tunnel preset '{expected_name}': {error}"
        ))
    })?;
    if preset.name != expected_name {
        return Err(RmuxError::Server(format!(
            "web-share tunnel preset '{expected_name}' declares name '{}'",
            preset.name
        )));
    }
    validate(&preset)?;
    Ok(preset)
}

fn validate(preset: &TunnelPreset) -> Result<(), RmuxError> {
    if !valid_name(&preset.name) {
        return Err(RmuxError::Server(format!(
            "web-share tunnel preset '{}' has an invalid name",
            preset.name
        )));
    }
    if preset.program.trim().is_empty() || preset.program.contains('\0') {
        return Err(RmuxError::Server(format!(
            "web-share tunnel preset '{}' must define a program",
            preset.name
        )));
    }
    if preset.args.iter().any(|arg| arg.contains('\0')) {
        return Err(RmuxError::Server(format!(
            "web-share tunnel preset '{}' contains an invalid argument",
            preset.name
        )));
    }
    if preset.ready_timeout_secs == 0 || preset.ready_timeout_secs > MAX_READY_TIMEOUT_SECS {
        return Err(RmuxError::Server(format!(
            "web-share tunnel preset '{}' ready_timeout_secs must be between 1 and {MAX_READY_TIMEOUT_SECS}",
            preset.name
        )));
    }
    Regex::new(&preset.url_pattern).map_err(|error| {
        RmuxError::Server(format!(
            "web-share tunnel preset '{}' has an invalid url_pattern: {error}",
            preset.name
        ))
    })?;
    if let Some(pattern) = preset.ready_pattern.as_deref() {
        Regex::new(pattern).map_err(|error| {
            RmuxError::Server(format!(
                "web-share tunnel preset '{}' has an invalid ready_pattern: {error}",
                preset.name
            ))
        })?;
    }
    Ok(())
}

fn preset_paths(name: &str) -> Vec<PathBuf> {
    preset_dirs()
        .into_iter()
        .map(|dir| dir.join(format!("{name}.toml")))
        .collect()
}

fn preset_dirs() -> Vec<PathBuf> {
    let mut dirs = Vec::new();
    if let Some(dir) = env::var_os(USER_PRESET_ENV) {
        dirs.push(PathBuf::from(dir));
    }
    if let Some(config_home) = env::var_os("XDG_CONFIG_HOME") {
        dirs.push(PathBuf::from(config_home).join("rmux/tunnels"));
    } else if let Some(home) = env::var_os("HOME") {
        dirs.push(PathBuf::from(home).join(".config/rmux/tunnels"));
    }
    dirs.push(PathBuf::from("/usr/local/share/rmux/tunnels"));
    dirs.push(PathBuf::from("/usr/share/rmux/tunnels"));
    dirs
}

fn no_preset_message(name: &str) -> String {
    let names = available();
    let locations = preset_dirs()
        .into_iter()
        .map(|path| format!("  {}", path.display()))
        .collect::<Vec<_>>()
        .join("\n");
    let available = if names.is_empty() {
        "none".to_owned()
    } else {
        names.join(", ")
    };
    format!(
        "no web-share tunnel preset named '{name}'. Available: {available}. Preset locations:\n{locations}"
    )
}

fn available() -> Vec<String> {
    available_from(
        embedded()
            .iter()
            .map(|(name, content)| ((*name).to_owned(), *content)),
        preset_dirs(),
    )
}

pub(super) fn available_from(
    embedded: impl IntoIterator<Item = (String, &'static str)>,
    dirs: Vec<PathBuf>,
) -> Vec<String> {
    let mut names = embedded
        .into_iter()
        .map(|(name, _)| name)
        .collect::<BTreeSet<_>>();
    for dir in dirs {
        if let Ok(entries) = fs::read_dir(dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                if path
                    .extension()
                    .is_some_and(|extension| extension == "toml")
                {
                    if let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) {
                        if valid_name(stem) {
                            names.insert(stem.to_owned());
                        }
                    }
                }
            }
        }
    }
    names.into_iter().collect()
}

fn valid_name(name: &str) -> bool {
    !name.is_empty()
        && name
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
}

const fn default_ready_timeout_secs() -> u64 {
    DEFAULT_READY_TIMEOUT_SECS
}

#[cfg(test)]
mod tests {
    use super::{parse, PresetSource, UrlSource};

    #[test]
    fn parse_rejects_mismatched_name() {
        let error = parse(
            "expected",
            PresetSource::Embedded,
            r#"
name = "other"
program = "tool"
url_pattern = "https://example\\.test"
"#,
        )
        .expect_err("name mismatch rejected");
        assert!(error.to_string().contains("declares name"));
    }

    #[test]
    fn parse_uses_safe_defaults() {
        let preset = parse(
            "tool",
            PresetSource::Embedded,
            r#"
name = "tool"
program = "tool"
args = ["--port", "{port}"]
url_pattern = "https://example\\.test"
"#,
        )
        .expect("preset parses");
        assert_eq!(preset.url_source, UrlSource::Both);
        assert_eq!(preset.ready_timeout_secs, 30);
    }
}