rmux-server 0.10.0

Tokio daemon and request dispatcher for the RMUX terminal multiplexer.
Documentation
use std::io::Write;
use std::process::{Command, Stdio};

use rmux_core::{GridRenderOptions, ScreenCaptureRange, TerminalScreen};
use rmux_proto::TerminalSize;
use serde_json::{json, Value};

use super::*;

type OracleCase = (&'static str, &'static [u8], &'static [u8]);

fn sanitize(input: &[u8]) -> Vec<u8> {
    sanitize_for_role(WebShareConnectRole::Operator, input)
}

fn sanitize_for_role(role: WebShareConnectRole, input: &[u8]) -> Vec<u8> {
    let mut sanitizer = WebTerminalSanitizer::for_role(role);
    let mut output = Vec::new();
    sanitizer.push(input, &mut output);
    output
}

fn rmux_printable(input: &[u8]) -> Vec<u8> {
    let mut terminal = TerminalScreen::new(TerminalSize { cols: 120, rows: 1 }, 0);
    terminal.feed(input);
    let mut output = terminal
        .screen()
        .capture_transcript(ScreenCaptureRange::default(), GridRenderOptions::default());
    while output.last() == Some(&b'\n') {
        output.pop();
    }
    String::from_utf8(output)
        .expect("RMUX transcript is UTF-8")
        .chars()
        .filter(|character| !matches!(u32::from(*character), 0x80..=0x9f))
        .collect::<String>()
        .into_bytes()
}

fn oracle_cases() -> Vec<OracleCase> {
    vec![
        ("dcs-can", b"a\x1bPq\x18HIDDEN\x1b\\b", b"ab"),
        ("dcs-sub", b"a\x1bPq\x1aHIDDEN\x1b\\b", b"ab"),
        ("dcs-esc-csi", b"a\x1bPq\x1b[31mHIDDEN\x1b\\b", b"ab"),
        ("dcs-esc-final", b"a\x1bPq\x1bAHIDDEN\x1b\\b", b"ab"),
        ("dcs-bel", b"a\x1bPqpayload\x07HIDDEN\x1b\\b", b"ab"),
        ("dcs-entry-can", b"a\x1bP1\x18VISIBLEb", b"aVISIBLEb"),
        ("apc-can", b"a\x1b_payload\x18VISIBLEb", b"aVISIBLEb"),
        ("apc-sub", b"a\x1b_payload\x1aVISIBLEb", b"aVISIBLEb"),
        ("apc-bel", b"a\x1b_payload\x07HIDDEN\x1b\\b", b"ab"),
        ("pm-can", b"a\x1b^payload\x18VISIBLEb", b"aVISIBLEb"),
        ("pm-st", b"a\x1b^HIDDEN\x1b\\b", b"ab"),
        ("sos-sub", b"a\x1bXpayload\x1aVISIBLEb", b"aVISIBLEb"),
        ("sos-st", b"a\x1bXHIDDEN\x1b\\b", b"ab"),
        ("osc-can", b"a\x1b]2;title\x18VISIBLEb", b"aVISIBLEb"),
        ("osc-sub", b"a\x1b]2;title\x1aVISIBLEb", b"aVISIBLEb"),
        ("osc-bel", b"a\x1b]52;c;WA==\x07VISIBLEb", b"aVISIBLEb"),
        ("osc-st", b"a\x1b]52;c;WA==\x1b\\VISIBLEb", b"aVISIBLEb"),
        ("rename-st", b"a\x1bkWINDOWNAME\x1b\\b", b"ab"),
        ("rename-bel", b"a\x1bkWINDOWNAME\x07HIDDEN\x1b\\b", b"ab"),
        ("utf8-c1-dcs", b"a\xc2\x90VISIBLEb", b"aVISIBLEb"),
        ("utf8-c1-sos", b"a\xc2\x98VISIBLEb", b"aVISIBLEb"),
        ("utf8-c1-osc", b"a\xc2\x9dVISIBLEb", b"aVISIBLEb"),
        ("utf8-c1-pm", b"a\xc2\x9eVISIBLEb", b"aVISIBLEb"),
        ("utf8-c1-apc", b"a\xc2\x9fVISIBLEb", b"aVISIBLEb"),
        ("bare-c1", b"a\x9dVISIBLEb", "a\u{fffd}VISIBLEb".as_bytes()),
    ]
}

fn assert_xterm_vectors(vectors: Vec<Value>) {
    let oracle = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../../tests/xterm-oracle/recovery-oracle.mjs");
    let mut child = Command::new("node")
        .arg(oracle)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("start pinned xterm.js sanitizer oracle");
    child
        .stdin
        .take()
        .expect("oracle stdin")
        .write_all(json!({ "vectors": vectors }).to_string().as_bytes())
        .expect("write sanitizer vectors");
    let output = child.wait_with_output().expect("wait for xterm.js oracle");
    assert!(
        output.status.success(),
        "xterm.js sanitizer oracle failed:\nstdout={}\nstderr={}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

#[test]
fn sanitized_bytes_match_rmux_printable_text() {
    for (name, input, expected) in oracle_cases() {
        assert_eq!(rmux_printable(input), expected, "RMUX owner: {name}");
        assert_eq!(sanitize(input), expected, "sanitized bytes: {name}");
    }
}

#[test]
#[ignore = "requires the pinned xterm.js package installed from package-lock.json"]
fn sanitized_bytes_match_pinned_xterm_viewer() {
    let vectors = oracle_cases()
        .into_iter()
        .map(|(name, input, expected)| {
            assert_eq!(rmux_printable(input), expected, "RMUX owner: {name}");
            json!({
                "name": name,
                "cols": 120,
                "rows": 8,
                "scrollback": 0,
                "initial": expected,
                "keyframe": sanitize(input),
                "tail": []
            })
        })
        .collect::<Vec<_>>();
    assert_xterm_vectors(vectors);
}

#[test]
#[ignore = "requires the pinned xterm.js package installed from package-lock.json"]
fn rejected_osc_8_closes_prior_hyperlink_in_pinned_xterm() {
    let input = concat!(
        "\u{1b}]8;;https://old.example\u{1b}\\",
        "OLD",
        "\u{1b}]8;;file:///etc/passwd\u{1b}\\",
        "NEXT",
        "\u{1b}]8;;\u{1b}\\",
        "END",
    )
    .as_bytes();
    let expected = concat!(
        "\u{1b}]8;;https://old.example\u{1b}\\",
        "OLD",
        "\u{1b}]8;;\u{1b}\\",
        "NEXT",
        "\u{1b}]8;;\u{1b}\\",
        "END",
    )
    .as_bytes();
    let mut vectors = [
        WebShareConnectRole::Operator,
        WebShareConnectRole::Spectator,
    ]
    .into_iter()
    .map(|role| {
        json!({
            "name": format!("rejected-osc-8-closes-prior-link-{role:?}"),
            "cols": 120,
            "rows": 2,
            "scrollback": 0,
            "initial": expected,
            "keyframe": sanitize_for_role(role, input),
            "tail": []
        })
    })
    .collect::<Vec<_>>();

    let blocked_clipboard = concat!(
        "\u{1b}]8;;https://old.example\u{1b}\\",
        "OLD",
        "\u{1b}]52;c;WA==\u{7}",
        "NEXT",
        "\u{1b}]8;;\u{1b}\\",
        "END",
    )
    .as_bytes();
    let blocked_clipboard_expected = concat!(
        "\u{1b}]8;;https://old.example\u{1b}\\",
        "OLDNEXT",
        "\u{1b}]8;;\u{1b}\\",
        "END",
    )
    .as_bytes();
    vectors.extend(
        [
            WebShareConnectRole::Operator,
            WebShareConnectRole::Spectator,
        ]
        .into_iter()
        .map(|role| {
            json!({
                "name": format!("blocked-non-hyperlink-osc-keeps-prior-link-{role:?}"),
                "cols": 120,
                "rows": 2,
                "scrollback": 0,
                "initial": blocked_clipboard_expected,
                "keyframe": sanitize_for_role(role, blocked_clipboard),
                "tail": []
            })
        }),
    );

    let spectator_metadata = concat!(
        "\u{1b}]8;;https://old.example\u{1b}\\",
        "OLD",
        "\u{1b}]2;private-title\u{7}",
        "NEXT",
        "\u{1b}]8;;\u{1b}\\",
        "END",
    )
    .as_bytes();
    vectors.push(json!({
        "name": "spectator-metadata-removal-keeps-prior-link",
        "cols": 120,
        "rows": 2,
        "scrollback": 0,
        "initial": blocked_clipboard_expected,
        "keyframe": sanitize_for_role(WebShareConnectRole::Spectator, spectator_metadata),
        "tail": []
    }));

    let rejected_then_allowed = concat!(
        "\u{1b}]8;;https://old.example\u{1b}\\",
        "OLD",
        "\u{1b}]8;;file:///blocked\u{1b}\\",
        "\u{1b}]8;;https://new.example\u{7}",
        "NEW",
        "\u{1b}]8;;\u{1b}\\",
        "END",
    )
    .as_bytes();
    let rejected_then_allowed_expected = concat!(
        "\u{1b}]8;;https://old.example\u{1b}\\",
        "OLD",
        "\u{1b}]8;;\u{1b}\\",
        "\u{1b}]8;;https://new.example\u{7}",
        "NEW",
        "\u{1b}]8;;\u{1b}\\",
        "END",
    )
    .as_bytes();
    vectors.extend(
        [
            WebShareConnectRole::Operator,
            WebShareConnectRole::Spectator,
        ]
        .into_iter()
        .map(|role| {
            json!({
                "name": format!("allowed-link-reopens-after-rejected-link-{role:?}"),
                "cols": 120,
                "rows": 2,
                "scrollback": 0,
                "initial": rejected_then_allowed_expected,
                "keyframe": sanitize_for_role(role, rejected_then_allowed),
                "tail": []
            })
        }),
    );

    let cancelled = concat!(
        "\u{1b}]8;;https://old.example\u{1b}\\",
        "OLD",
        "\u{1b}]8;;file:///cancelled\u{18}",
        "NEXT",
        "\u{1b}]8;;\u{1b}\\",
        "END",
    )
    .as_bytes();
    vectors.extend(
        [
            WebShareConnectRole::Operator,
            WebShareConnectRole::Spectator,
        ]
        .into_iter()
        .map(|role| {
            json!({
                "name": format!("cancelled-hyperlink-keeps-prior-link-{role:?}"),
                "cols": 120,
                "rows": 2,
                "scrollback": 0,
                "initial": blocked_clipboard_expected,
                "keyframe": sanitize_for_role(role, cancelled),
                "tail": []
            })
        }),
    );

    assert_xterm_vectors(vectors);
}