libtmux 0.1.0

Async typed tmux client and object model
Documentation
use std::ffi::OsStr;

use crate::{Command, Error, Layout, Server, TmuxVersion};

const NAMES: [Layout; 7] = [
    Layout::EvenHorizontal,
    Layout::EvenVertical,
    Layout::MainHorizontal,
    Layout::MainVertical,
    Layout::Tiled,
    Layout::MainHorizontalMirrored,
    Layout::MainVerticalMirrored,
];

fn named(input: &[u8], mirrored: bool) -> Option<Layout> {
    let names = &NAMES[..if mirrored { 7 } else { 5 }];
    if let Some(layout) = names
        .iter()
        .find(|layout| layout.as_str().as_bytes() == input)
    {
        return Some(*layout);
    }
    let mut matches = names
        .iter()
        .filter(|layout| layout.as_str().as_bytes().starts_with(input));
    let first = matches.next().copied()?;
    matches.next().is_none().then_some(first)
}

pub(crate) fn accepts(input: &[u8], panes: usize, mirrored: bool) -> bool {
    if input.is_empty() || panes == 0 {
        return false;
    }
    if named(input, mirrored).is_some() {
        return true;
    }
    if input.len() <= 5 || input[4] != b',' || !input[..4].iter().all(u8::is_ascii_hexdigit) {
        return false;
    }
    let expected = std::str::from_utf8(&input[..4])
        .ok()
        .and_then(|header| u16::from_str_radix(header, 16).ok());
    let body = &input[5..];
    let checksum = body.iter().fold(0_u16, |sum, byte| {
        sum.rotate_right(1).wrapping_add(u16::from(*byte))
    });
    if expected != Some(checksum) {
        return false;
    }
    let mut parser = Parser {
        input: body,
        offset: 0,
        leaves: 0,
    };
    parser.cell(0) && parser.offset == body.len() && parser.leaves >= panes
}

pub(crate) fn prepare<'a>(
    layouts: impl IntoIterator<Item = (&'a OsStr, usize)>,
) -> Result<Vec<(&'a OsStr, usize)>, Error> {
    let mut pending = Vec::new();
    for (layout, panes) in layouts {
        let bytes = layout.as_encoded_bytes();
        if bytes.starts_with(b"{") {
            pending.push((layout, panes));
            continue;
        }
        let before = accepts(bytes, panes, false);
        let after = accepts(bytes, panes, true);
        if !before && !after {
            return Err(Error::InvalidLayout {
                reason: "use a unique layout name or a checksummed nonempty tree with enough pane cells",
            });
        }
        if before != after {
            pending.push((layout, panes));
        }
    }
    Ok(pending)
}

pub(crate) fn resolve(pending: &[(&OsStr, usize)], version: &TmuxVersion) -> Result<(), Error> {
    let mirrored = version
        .behavior_release()
        .is_none_or(|release| release >= crate::since::MIRRORED_LAYOUTS);
    for (layout, panes) in pending {
        let bytes = layout.as_encoded_bytes();
        if bytes.starts_with(b"{") {
            version.require("a JSON layout string", crate::since::JSON_LAYOUTS)?;
            continue;
        }
        if !accepts(bytes, *panes, mirrored) {
            if let Some(layout) = named(bytes, true) {
                version.require(layout.as_str(), layout.minimum_release())?;
            }
            return Err(Error::InvalidLayout {
                reason: "use a layout name that is unique on the running tmux version",
            });
        }
    }
    Ok(())
}

pub(crate) async fn version(server: &Server) -> Result<TmuxVersion, Error> {
    server.endpoint_version().await
}

/// A version probe that fires no user hook.
///
/// `display-message` runs the `after-display-message` hook on every release,
/// so asking the daemon its version with it already had effects. `run-shell
/// -C` has no `after-` hook, and it parses its argument in a timer callback
/// after `exec` returned, so tmux never queues `command-error` for it either.
/// The argument is not a command: tmux expands the format, fails to parse
/// `<marker><version>`, and reports `unknown command: <marker><version>` on
/// stderr with exit status 1.
pub(crate) struct VersionProbe {
    marker: String,
}

impl VersionProbe {
    pub(crate) fn new() -> Self {
        use std::hash::{BuildHasher as _, Hasher as _};

        // `RandomState` is seeded by the operating system, so a hook, alias
        // or session name cannot guess the marker to forge a version report.
        let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
        hasher.write_u32(std::process::id());
        Self {
            marker: format!("libtmux-version-{:016x}-", hasher.finish()),
        }
    }

    pub(crate) fn command(&self) -> Command {
        self.command_for("#{version}")
    }

    /// The probe for a format of the caller's own. tmux reports only the first
    /// word of what it fails to parse, so the format must contain no space.
    pub(crate) fn command_for(&self, format: &str) -> Command {
        Command::new("run-shell")
            .arg("-C")
            .arg(format!("{}{format}", self.marker))
    }

    /// The text tmux expanded after the marker, when `stderr` is this probe's
    /// refusal.
    pub(crate) fn report<'a>(&self, stderr: &'a [u8]) -> Option<&'a [u8]> {
        stderr
            .trim_ascii()
            .strip_prefix(b"unknown command: ")?
            .strip_prefix(self.marker.as_bytes())
    }

    /// Read the daemon's version from the probe's refusal.
    ///
    /// Returns `Ok(None)` when `stderr` is not this probe's report, so the
    /// caller can tell an unrelated failure from an unparseable version. A
    /// report that names no release is [`Error::UnrecognizedTmuxVersion`].
    pub(crate) fn parse(&self, stderr: &[u8]) -> Result<Option<TmuxVersion>, Error> {
        let Some(version) = self.report(stderr) else {
            return Ok(None);
        };
        let mut framed = b"tmux ".to_vec();
        framed.extend_from_slice(version);
        framed.push(b'\n');
        TmuxVersion::parse_report(&framed, stderr.len()).map(Some)
    }
}

struct Parser<'a> {
    input: &'a [u8],
    offset: usize,
    leaves: usize,
}

impl Parser<'_> {
    fn take(&mut self, byte: u8) -> bool {
        if self.input.get(self.offset) != Some(&byte) {
            return false;
        }
        self.offset += 1;
        true
    }

    fn number(&mut self) -> bool {
        let start = self.offset;
        let mut value = 0_u32;
        while let Some(digit) = self.input.get(self.offset) {
            if !digit.is_ascii_digit() {
                break;
            }
            let Some(next) = value
                .checked_mul(10)
                .and_then(|value| value.checked_add(u32::from(digit - b'0')))
            else {
                return false;
            };
            value = next;
            self.offset += 1;
        }
        self.offset > start
    }

    fn cell(&mut self, depth: usize) -> bool {
        if depth > 256
            || !self.number()
            || !self.take(b'x')
            || !self.number()
            || !self.take(b',')
            || !self.number()
            || !self.take(b',')
            || !self.number()
        {
            return false;
        }
        let saved = self.offset;
        if self.take(b',') && (!self.number() || self.input.get(self.offset) == Some(&b'x')) {
            self.offset = saved;
        }
        let close = if self.take(b'{') {
            b'}'
        } else if self.take(b'[') {
            b']'
        } else {
            self.leaves += 1;
            return true;
        };
        if !self.cell(depth + 1) {
            return false;
        }
        while self.take(b',') {
            if !self.cell(depth + 1) {
                return false;
            }
        }
        self.take(close)
    }
}

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

    fn serialized(body: &[u8]) -> Vec<u8> {
        let checksum = body.iter().fold(0_u16, |sum, byte| {
            sum.rotate_right(1).wrapping_add(u16::from(*byte))
        });
        [format!("{checksum:04x},").as_bytes(), body].concat()
    }

    #[test]
    fn layout_preflight_shared_corpus_keeps_geometry_with_tmux() {
        let corpus: serde_json::Value =
            serde_json::from_str(include_str!("../tests/fixtures/layout-preflight.json")).unwrap();
        for case in corpus.as_array().unwrap() {
            let id = case["id"].as_str().unwrap();
            let layout = case["layout"].as_str().unwrap();
            let panes = usize::try_from(case["pane_count"].as_u64().unwrap()).unwrap();
            for (version, mirrored) in [("3.2a", false), ("3.3a", false), ("3.7c", true)] {
                let geometry = matches!(
                    id,
                    "bad-inner-size" | "nested-invalid-width" | "nested-short-parent"
                );
                let expected = !layout.is_empty()
                    && (geometry || case["expected_valid"][version].as_bool().unwrap());
                assert_eq!(
                    accepts(layout.as_bytes(), panes, mirrored),
                    expected,
                    "{version} {id}"
                );
            }
        }
    }

    #[test]
    fn layout_preflight_unsigned_fields_depth_and_bytes() {
        assert!(accepts(
            &serialized(b"4294967295x0,0,0,4294967295"),
            1,
            false
        ));
        for body in [
            b"4294967296x1,0,0".as_slice(),
            b"1x1,0,4294967296",
            b"1x1,0,0,4294967296",
            b"1x1,0,0{}",
            b"1x1,0,0[]",
            b"\xffx1,0,0",
        ] {
            assert!(!accepts(&serialized(body), 1, true));
        }
        let nested = format!("{}1x1,0,0{}", "1x1,0,0{".repeat(256), "}".repeat(256));
        assert!(accepts(&serialized(nested.as_bytes()), 1, true));
        assert!(!accepts(
            &serialized(format!("1x1,0,0{{{nested}}}").as_bytes()),
            1,
            true
        ));
        let long = format!("{}1x1,0,0", "0".repeat(9000));
        assert!(accepts(&serialized(long.as_bytes()), 1, true));
        assert!(!accepts(b"tiled", 0, true));
    }

    #[test]
    fn layout_preflight_retains_native_version_and_error_semantics() {
        let old = TmuxVersion::parse_output(b"tmux 3.2a\n").unwrap();
        let new = TmuxVersion::parse_output(b"tmux 3.7c\n").unwrap();
        let json = [(OsStr::new(r#"{"V":2,"L":[]}"#), 1)];
        let pending = prepare(json).unwrap();
        for output in ["tmux 3.7c\n", "tmux 3.8-rc\n"] {
            let json_incapable = TmuxVersion::parse_output(output.as_bytes()).unwrap();
            assert_eq!(
                resolve(&pending, &json_incapable).unwrap_err().kind(),
                crate::ErrorKind::UnsupportedVersion,
                "{output}"
            );
        }
        assert_eq!(
            resolve(&pending, &new).unwrap_err().kind(),
            crate::ErrorKind::UnsupportedVersion
        );
        for output in [
            "tmux 3.8-rc2\n",
            "tmux 3.8-rc3\n",
            "tmux 3.8\n",
            "tmux next-3.9\n",
        ] {
            let json_capable = TmuxVersion::parse_output(output.as_bytes()).unwrap();
            resolve(&pending, &json_capable).unwrap();
        }
        let mirrored = [(OsStr::new("main-horizontal-mirrored"), 1)];
        assert_eq!(
            resolve(&prepare(mirrored).unwrap(), &old)
                .unwrap_err()
                .kind(),
            crate::ErrorKind::UnsupportedVersion
        );
        resolve(&prepare(mirrored).unwrap(), &new).unwrap();
        let abbreviation = [(OsStr::new("main-h"), 1)];
        resolve(&prepare(abbreviation).unwrap(), &old).unwrap();
        let error = resolve(&prepare(abbreviation).unwrap(), &new).unwrap_err();
        assert_eq!(error.kind(), crate::ErrorKind::InvalidInput);
        assert!(!error.is_transient());
    }
}