tastty-core 0.1.0

Sans-IO core of the tastty terminal session library: VT parser, screen buffer, and byte encoders.
use crate::attrs::Color;

const TERMINAL_NAME: &str = "tastty";
const DEFAULT_LIGHT_GRAY: Color = Color::Rgb(229, 229, 229);

const DEFAULT_OSC_MAX_BYTES_NORMAL: usize = 10 * 1024;
const DEFAULT_OSC_MAX_BYTES_CLIPBOARD: usize = 64 * 1024;
const DEFAULT_DCS_MAX_BYTES_DECRQSS: usize = 4 * 1024;
const DEFAULT_DCS_MAX_BYTES_XTGETTCAP: usize = 64 * 1024;
const DEFAULT_CLIPBOARD_MAX_BYTES: usize = 1024 * 1024;
const DEFAULT_TITLE_STACK_DEPTH: u16 = 10;

/// Terminal identity, default color configuration, and resource caps.
///
/// Controls how the terminal identifies itself to programs, what default
/// colors to use, and how large guest-supplied OSC / DCS / clipboard
/// payloads are allowed to grow before the parser drops them. `tastty-core`
/// consumers pass a `HostProfile` through
/// [`Parser::with_profile`](crate::Parser::with_profile); `tastty` exposes
/// it via `SessionOptions::host_profile`.
/// All fields have sensible defaults matching a VT220-compatible terminal.
///
/// The `default_fg`, `default_bg`, and `default_cursor_color` fields are
/// expected to be concrete [`Color::Rgb`] values: [OSC 10/11/12][xterm-osc]
/// query replies report their RGB channels back to the program. Setting
/// them to [`Color::Default`] or [`Color::Index`] is allowed but the OSC
/// reply path will substitute black for non-RGB variants.
///
/// [xterm-osc]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
///
/// The `*_max_bytes_*`, `clipboard_max_bytes`, and `title_stack_depth`
/// fields exist so embedders can tune the parser's memory footprint
/// against an adversarial guest. Defaults are chosen to sit well above
/// realistic interactive-program payloads while keeping pathological
/// streams cheap to discard. Set them lower for tight environments;
/// raising them widens the DoS surface.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct HostProfile {
    /// DA1 response bytes (CSI ? ... c). Default: VT220 + ANSI color + OSC 52.
    pub da1: Vec<u8>,
    /// DA2 response bytes (CSI > ... c). Default: type 0, version 0.
    pub da2: Vec<u8>,
    /// DA3 response bytes (DCS ! | hex ST). Default: hex("tastty").
    pub da3: Vec<u8>,
    /// Version string for XTVERSION (CSI > q). Default: "tastty({version})".
    pub xtversion_name: String,
    /// Terminal name for XTGETTCAP TN query. Default: "tastty".
    pub terminfo_name: String,
    /// Default foreground color. Programs can override via OSC 10.
    pub default_fg: Color,
    /// Default background color. Programs can override via OSC 11.
    pub default_bg: Color,
    /// Default cursor color. Programs can override via OSC 12.
    pub default_cursor_color: Color,
    /// Total bytes allowed in a single non-clipboard OSC payload before
    /// the parser drops the entire frame (no event emitted). Default:
    /// 10 KiB. Counted across every `;`-separated parameter that the
    /// parser hands the screen, so a 9 KiB title with a 2 KiB suffix
    /// will exceed the cap.
    pub osc_max_bytes_normal: usize,
    /// Total bytes allowed in a single clipboard-class OSC payload
    /// (OSC 52, OSC 66, OSC 5522) before the parser drops the entire
    /// frame. Default: 64 KiB. OSC 52 has an additional decoded-byte
    /// cap; see [`Self::clipboard_max_bytes`].
    pub osc_max_bytes_clipboard: usize,
    /// Total bytes allowed in a DECRQSS DCS payload (`DCS $ q ... ST`)
    /// before the parser drops the query (no event emitted, no reply).
    /// Default: 4 KiB. Real DECRQSS queries never exceed a handful of
    /// bytes; the cap exists to bound the buffer a misbehaving guest
    /// can grow.
    pub dcs_max_bytes_decrqss: usize,
    /// Total bytes allowed in an XTGETTCAP DCS payload (`DCS + q ... ST`)
    /// before the parser drops the frame. Default: 64 KiB. Termcap
    /// queries are conventionally small, but a guest may legitimately
    /// batch many keys per frame; this default leaves headroom.
    pub dcs_max_bytes_xtgettcap: usize,
    /// Maximum decoded payload size for an [OSC 52][osc52] clipboard
    /// write, measured *after* [base64][rfc4648] decoding. Default:
    /// 1 MiB. Writes whose pre-decode upper bound exceeds this value
    /// are rejected without allocating the decoded buffer. Both
    /// rejection paths emit
    /// [`ScreenEvent::ClipboardWriteRejected`](crate::ScreenEvent::ClipboardWriteRejected)
    /// so embedders can surface the failure.
    ///
    /// [osc52]: https://gist.github.com/egmontkob/eb8d45597f7db55ec41d6c0ffc6f3bb3
    /// [rfc4648]: https://www.rfc-editor.org/rfc/rfc4648
    pub clipboard_max_bytes: usize,
    /// Maximum number of entries retained in the XTWINOPS title stack
    /// (`CSI 22 t` / `CSI 23 t`). Default: 10. Pushes past the cap drop
    /// the oldest entry; pops on an empty stack are no-ops.
    pub title_stack_depth: u16,
}

impl Default for HostProfile {
    fn default() -> Self {
        let hex_name: String = TERMINAL_NAME.bytes().map(|b| format!("{b:02X}")).collect();
        Self {
            da1: b"\x1b[?62;22;52c".to_vec(),
            da2: b"\x1b[>0;0;0c".to_vec(),
            da3: format!("\x1bP!|{hex_name}\x1b\\").into_bytes(),
            xtversion_name: format!("{TERMINAL_NAME}({})", env!("CARGO_PKG_VERSION")),
            terminfo_name: TERMINAL_NAME.to_string(),
            default_fg: DEFAULT_LIGHT_GRAY,
            default_bg: Color::Rgb(0, 0, 0),
            default_cursor_color: DEFAULT_LIGHT_GRAY,
            osc_max_bytes_normal: DEFAULT_OSC_MAX_BYTES_NORMAL,
            osc_max_bytes_clipboard: DEFAULT_OSC_MAX_BYTES_CLIPBOARD,
            dcs_max_bytes_decrqss: DEFAULT_DCS_MAX_BYTES_DECRQSS,
            dcs_max_bytes_xtgettcap: DEFAULT_DCS_MAX_BYTES_XTGETTCAP,
            clipboard_max_bytes: DEFAULT_CLIPBOARD_MAX_BYTES,
            title_stack_depth: DEFAULT_TITLE_STACK_DEPTH,
        }
    }
}