tastty 0.1.0

Embeddable pseudoterminal sessions for Rust applications
//! Allow / deny gating for clipboard OSC sequences.
//!
//! The default posture is read = deny, write = allow: a hostile shell that
//! injects [`OSC 52 ; c ; ?`][osc52] to exfiltrate clipboard contents over
//! the PTY cannot succeed without the embedder explicitly opting that
//! buffer back in, while a legitimate copy-to-clipboard sequence
//! (`OSC 52 ; c ; <b64>`) still surfaces. Per-target overrides on
//! [`SessionOptions`] flip individual buffers in either direction.
//!
//! [`SessionOptions`]: crate::SessionOptions
//! [osc52]: https://gist.github.com/egmontkob/eb8d45597f7db55ec41d6c0ffc6f3bb3

use tastty_core::ClipboardTarget;

/// Whether a controllable terminal feature is permitted.
///
/// Approval-flow integrations (per-event prompts, asynchronous callbacks)
/// are expected to live in the embedder, not in `tastty`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OscPolicy {
    /// Forward the event to the embedder. Any auto-reply that
    /// [`auto_reply_bytes`](tastty_core::host_reply::auto_reply_bytes)
    /// would generate runs as usual.
    Allow,
    /// Drop the event silently. The embedder never observes it and no
    /// auto-reply bytes are written back to the child.
    Deny,
}

/// Per-buffer allow / deny decisions for one OSC 52 direction.
///
/// One field exists per buffer code defined by OSC 52: `c` clipboard,
/// `p` primary selection, `q` secondary selection, `s` configurable
/// select, and `0`..`7` numeric cut buffers (one shared decision since
/// embedders rarely distinguish among them in practice).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PerTargetPolicy {
    /// `OSC 52 ; c` -- system clipboard.
    pub clipboard: OscPolicy,
    /// `OSC 52 ; p` -- primary selection.
    pub primary: OscPolicy,
    /// `OSC 52 ; q` -- secondary selection.
    pub secondary: OscPolicy,
    /// `OSC 52 ; s` -- configurable selection (the xterm default buffer
    /// when no explicit code is supplied).
    pub select: OscPolicy,
    /// `OSC 52 ; 0`..`7` -- shared decision for every numeric cut buffer.
    pub cut_buffer: OscPolicy,
}

impl PerTargetPolicy {
    /// Allow every OSC 52 buffer.
    #[must_use]
    pub const fn allow_all() -> Self {
        Self {
            clipboard: OscPolicy::Allow,
            primary: OscPolicy::Allow,
            secondary: OscPolicy::Allow,
            select: OscPolicy::Allow,
            cut_buffer: OscPolicy::Allow,
        }
    }

    /// Deny every OSC 52 buffer.
    #[must_use]
    pub const fn deny_all() -> Self {
        Self {
            clipboard: OscPolicy::Deny,
            primary: OscPolicy::Deny,
            secondary: OscPolicy::Deny,
            select: OscPolicy::Deny,
            cut_buffer: OscPolicy::Deny,
        }
    }

    /// Resolve the policy for `target`.
    #[must_use]
    pub fn for_target(self, target: ClipboardTarget) -> OscPolicy {
        match target {
            ClipboardTarget::Clipboard => self.clipboard,
            ClipboardTarget::Primary => self.primary,
            ClipboardTarget::Secondary => self.secondary,
            ClipboardTarget::Select => self.select,
            ClipboardTarget::CutBuffer(_) => self.cut_buffer,
        }
    }

    pub(crate) fn set(&mut self, target: ClipboardTarget, policy: OscPolicy) {
        match target {
            ClipboardTarget::Clipboard => self.clipboard = policy,
            ClipboardTarget::Primary => self.primary = policy,
            ClipboardTarget::Secondary => self.secondary = policy,
            ClipboardTarget::Select => self.select = policy,
            ClipboardTarget::CutBuffer(_) => self.cut_buffer = policy,
        }
    }
}

/// [OSC 52][osc52] read / write policy applied at the reader thread
/// before any event reaches the embedder or [`auto_reply_bytes`].
///
/// The two directions are independent: the read side guards
/// [`ScreenEvent::ClipboardQuery`] (the `?` payload), and the write side
/// guards both [`ScreenEvent::ClipboardWrite`] (decoded payload) and
/// [`ScreenEvent::ClipboardClear`] (empty payload). Mixed-target events
/// are denied if any one of their targets is denied; partial allow is
/// not modeled because OSC 52 wire semantics treat the target list as a
/// single unit.
///
/// [`auto_reply_bytes`]: tastty_core::host_reply::auto_reply_bytes
/// [`ScreenEvent::ClipboardQuery`]: tastty_core::ScreenEvent::ClipboardQuery
/// [`ScreenEvent::ClipboardWrite`]: tastty_core::ScreenEvent::ClipboardWrite
/// [`ScreenEvent::ClipboardClear`]: tastty_core::ScreenEvent::ClipboardClear
/// [osc52]: https://gist.github.com/egmontkob/eb8d45597f7db55ec41d6c0ffc6f3bb3
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ClipboardPolicy {
    /// Decision for `OSC 52 ; <Pc> ; ?` queries.
    pub read: PerTargetPolicy,
    /// Decision for `OSC 52 ; <Pc> ; <base64>` writes and
    /// `OSC 52 ; <Pc> ;` clears.
    pub write: PerTargetPolicy,
}

impl Default for ClipboardPolicy {
    fn default() -> Self {
        Self {
            read: PerTargetPolicy::deny_all(),
            write: PerTargetPolicy::allow_all(),
        }
    }
}

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

    #[test]
    fn default_is_read_deny_write_allow() {
        let policy = ClipboardPolicy::default();
        assert_eq!(
            policy.read.for_target(ClipboardTarget::Clipboard),
            OscPolicy::Deny,
        );
        assert_eq!(
            policy.write.for_target(ClipboardTarget::Clipboard),
            OscPolicy::Allow,
        );
    }

    #[test]
    fn cut_buffer_field_covers_every_index() {
        let mut policy = PerTargetPolicy::allow_all();
        policy.cut_buffer = OscPolicy::Deny;
        for index in 0u8..=7 {
            assert_eq!(
                policy.for_target(ClipboardTarget::CutBuffer(index)),
                OscPolicy::Deny,
            );
        }
    }

    #[test]
    fn set_writes_back_through_for_target() {
        let mut policy = PerTargetPolicy::deny_all();
        policy.set(ClipboardTarget::Primary, OscPolicy::Allow);
        assert_eq!(
            policy.for_target(ClipboardTarget::Primary),
            OscPolicy::Allow,
        );
        assert_eq!(
            policy.for_target(ClipboardTarget::Clipboard),
            OscPolicy::Deny,
        );
    }
}