Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions crates/roost-iced/src/app/interactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1013,14 +1013,7 @@ pub(super) fn paste_bytes(terminal: &Terminal, text: Option<&str>) -> Vec<u8> {
let Some(text) = text.filter(|text| !text.is_empty()) else {
return Vec::new();
};
if !terminal.mode_get(2004) {
return text.as_bytes().to_vec();
}
let mut bytes = Vec::with_capacity(text.len() + 12);
bytes.extend_from_slice(b"\x1b[200~");
bytes.extend_from_slice(text.as_bytes());
bytes.extend_from_slice(b"\x1b[201~");
bytes
roost_ui_model::bracketed_paste::wrap(text, terminal.mode_get(2004))
}

impl App {
Expand Down Expand Up @@ -2275,6 +2268,11 @@ mod tests {
paste_bytes(&terminal, Some("hello\n")),
b"\x1b[200~hello\n\x1b[201~"
);
// A clipboard carrying the end marker can't close the region early.
assert_eq!(
paste_bytes(&terminal, Some("\x1b[201~rm -rf /\n")),
b"\x1b[200~rm -rf /\n\x1b[201~"
);
}

#[cfg(target_os = "macos")]
Expand Down
16 changes: 4 additions & 12 deletions crates/roost-linux/src/terminal_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2448,8 +2448,9 @@ fn selection_text(state: &Rc<RefCell<TerminalViewState>>) -> Option<String> {
}
}

/// Feed pasted `text` into the PTY, wrapping in bracketed-paste escapes
/// (`ESC[200~` … `ESC[201~`) when DECSET 2004 is active. Shared by
/// Feed pasted `text` into the PTY through `bracketed_paste::wrap`, which
/// frames it in `ESC[200~` … `ESC[201~` (and strips embedded markers) when
/// DECSET 2004 is active. Shared by
/// Ctrl+Shift+V (CLIPBOARD) and, on Linux, middle-click (PRIMARY); the
/// async clipboard read lives in `clipboard::read`. Reads the callback
/// out of the borrow before invoking, per the callback invariant on
Expand All @@ -2467,16 +2468,7 @@ fn paste_text_into(state: &Rc<RefCell<TerminalViewState>>, text: String) {
(s.terminal.mode_get(2004), s.input_callback.clone())
};
let Some(cb) = cb else { return };
let bytes = if bracketed {
let mut buf = Vec::with_capacity(text.len() + 8);
buf.extend_from_slice(b"\x1b[200~");
buf.extend_from_slice(text.as_bytes());
buf.extend_from_slice(b"\x1b[201~");
buf
} else {
text.into_bytes()
};
cb(bytes);
cb(roost_ui_model::bracketed_paste::wrap(&text, bracketed));
}

/// Install a destination-only file/text drop handler on the terminal.
Expand Down
104 changes: 104 additions & 0 deletions crates/roost-ui-model/src/bracketed_paste.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//! Toolkit-neutral bracketed-paste framing for pasted / dropped payloads.
//!
//! Mirrors `mac/Sources/Roost/BracketedPaste.swift` (`wrapBracketedPaste`);
//! the two implementations share unit-test vectors so they stay
//! byte-identical, following the `shell_escape` precedent.

const START: &[u8] = b"\x1b[200~";
const END: &[u8] = b"\x1b[201~";
const MARKER_LEN: usize = START.len();

/// Frame `text` for the PTY.
///
/// When `bracketed` (DECSET 2004 active) the payload is wrapped in
/// `ESC[200~` … `ESC[201~` and every embedded `ESC[200~` / `ESC[201~` is
/// removed first — a clipboard carrying `ESC[201~rm -rf /` would otherwise
/// close the region early and the tail would reach the shell as typed input.
/// Removal (rather than escaping) matches the upstream terminal convention:
/// there is no in-band way to quote a marker, so it is neutralized.
///
/// Without `bracketed` the bytes pass through unchanged — the receiving
/// program is reading raw input, so there is no region to break out of.
/// Empty input yields no bytes so callers never emit a bare
/// `ESC[200~ESC[201~`.
pub fn wrap(text: &str, bracketed: bool) -> Vec<u8> {
if text.is_empty() {
return Vec::new();
}
if !bracketed {
return text.as_bytes().to_vec();
}
let mut out = Vec::with_capacity(text.len() + START.len() + END.len());
out.extend_from_slice(START);
for &byte in text.as_bytes() {
out.push(byte);
// Match on the *output* tail, not the input: dropping one marker can
// splice its neighbours into a fresh one (`ESC[20` + `ESC[200~` +
// `0~`), which a single input-side pass would let through. The
// `>= MARKER_LEN` floor keeps the opening `START` out of the window.
let tail = out.len().saturating_sub(MARKER_LEN);
if tail >= MARKER_LEN && (&out[tail..] == START || &out[tail..] == END) {
out.truncate(tail);
}
}
out.extend_from_slice(END);
out
}

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

#[test]
fn empty_text_yields_no_bytes() {
assert!(wrap("", false).is_empty());
assert!(wrap("", true).is_empty());
}

#[test]
fn passthrough_without_bracketed_mode() {
assert_eq!(wrap("hello\n", false), b"hello\n");
// No 2004, no region to break: the bytes are delivered verbatim.
assert_eq!(wrap("a\x1b[201~b", false), b"a\x1b[201~b");
}

#[test]
fn plain_payload_is_wrapped_once() {
assert_eq!(wrap("hello\n", true), b"\x1b[200~hello\n\x1b[201~");
}

#[test]
fn embedded_end_marker_is_removed() {
assert_eq!(
wrap("\x1b[201~rm -rf /\n", true),
b"\x1b[200~rm -rf /\n\x1b[201~"
);
}

#[test]
fn embedded_start_marker_is_removed() {
assert_eq!(wrap("a\x1b[200~b", true), b"\x1b[200~ab\x1b[201~");
}

/// Removal is re-checked against the output, so halves left adjacent by an
/// earlier removal cannot re-form a marker.
#[test]
fn removal_cannot_splice_a_new_marker() {
assert_eq!(wrap("x\x1b[20\x1b[200~0~y", true), b"\x1b[200~xy\x1b[201~");
}

/// Only the contiguous six-byte sequence is a marker; a truncated prefix
/// is ordinary payload.
#[test]
fn partial_marker_is_preserved() {
assert_eq!(wrap("\x1b[201", true), b"\x1b[200~\x1b[201\x1b[201~");
}

#[test]
fn utf8_around_removals_is_preserved() {
assert_eq!(
wrap("图\x1b[201~片", true),
"\x1b[200~图片\x1b[201~".as_bytes()
);
}
}
20 changes: 17 additions & 3 deletions crates/roost-ui-model/src/drop_content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ use crate::shell_escape;
/// input.
///
/// Paths are de-duplicated by their raw platform representation before UTF-8
/// conversion. Non-UTF-8 and newline-bearing paths are ignored instead of
/// being lossily changed into a different filename or multiple shell lines.
/// conversion. Non-UTF-8 paths and paths bearing a control character no
/// filename may legitimately carry (`\n`, `\r`, ESC) are ignored rather than
/// repaired: a newline would split the join into bogus extra shell lines and an
/// ESC would smuggle a control sequence (e.g. a bracketed-paste marker) into
/// the PTY, while stripping either would silently turn the path into a
/// different filename. Rejecting keeps `shell_escape::escape` lossless.
/// If at least one safe path remains, paths take priority over `text` and are
/// newline-joined in first-seen order. Otherwise non-empty text is returned
/// verbatim.
Expand All @@ -32,7 +36,7 @@ where
return None;
}
let path = path.to_str()?;
(!path.contains(['\n', '\r'])).then(|| shell_escape::escape(path))
(!path.contains(['\n', '\r', '\u{1b}'])).then(|| shell_escape::escape(path))
})
.collect::<Vec<_>>();
if !escaped.is_empty() {
Expand Down Expand Up @@ -78,6 +82,16 @@ mod tests {
);
}

/// Shared with the Swift `testControlBearingPathIsDropped` vector.
#[test]
fn escape_bearing_paths_are_rejected() {
assert_eq!(resolve(["/tmp/ev\u{1b}[201~il.png"], None), None);
assert_eq!(
resolve(["/tmp/ev\u{1b}[201~il.png", "/tmp/ok.png"], None),
Some("/tmp/ok.png".to_string())
);
}

#[cfg(unix)]
#[test]
fn non_utf8_paths_are_rejected_without_lossy_replacement() {
Expand Down
1 change: 1 addition & 0 deletions crates/roost-ui-model/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#![deny(unsafe_op_in_unsafe_fn)]

pub mod agent_palette;
pub mod bracketed_paste;
pub mod config;
pub mod custom_command;
pub mod drop_content;
Expand Down
19 changes: 19 additions & 0 deletions crates/roost-ui-model/src/shell_escape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
/// non-ASCII codepoints (e.g. the U+202F narrow-no-break space in a macOS
/// screenshot filename) pass through unchanged, which modern shells handle as
/// UTF-8 literals.
///
/// This is a pure escaper: every input character reaches the output, so the
/// escaped string still names the same file. Control characters that no
/// filename may legitimately carry (`\n`, `\r`, ESC) are rejected earlier, at
/// the drop boundary in `drop_content::resolve` — dropping them here would let
/// two distinct filenames collapse to the same PTY input.
pub fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
Expand Down Expand Up @@ -107,6 +113,19 @@ mod tests {
assert_eq!(escape("\\ "), "\\\\\\ ");
}

/// Shared verbatim with the Swift `testEscapeBytePassesThrough` vector: the
/// escaper never drops input (that would make the escaped string name a
/// different file), so ESC survives and only the `[` after it is escaped.
/// ESC-bearing paths are rejected up in `drop_content::resolve`.
#[test]
fn escape_byte_passes_through() {
assert_eq!(
escape("/tmp/ev\u{1b}[201~il.png"),
"/tmp/ev\u{1b}\\[201~il.png"
);
assert_eq!(escape("\u{1b}"), "\u{1b}");
}

#[test]
fn non_ascii_passes_through() {
assert_eq!(escape("/tmp/图 片.png"), "/tmp/图\\ 片.png");
Expand Down
46 changes: 46 additions & 0 deletions mac/Sources/Roost/BracketedPaste.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Bracketed-paste framing for pasted / dropped payloads.
//
// Mirrors `crates/roost-ui-model/src/bracketed_paste.rs` (`bracketed_paste::wrap`);
// the two implementations share unit-test vectors so they stay byte-identical,
// following the `ShellEscape` precedent.

import Foundation

private let bracketedPasteStart: [UInt8] = [0x1b, 0x5b, 0x32, 0x30, 0x30, 0x7e]
private let bracketedPasteEnd: [UInt8] = [0x1b, 0x5b, 0x32, 0x30, 0x31, 0x7e]

/// Frame `payload` for the PTY.
///
/// When `bracketed` (DECSET 2004 active) the payload is wrapped in
/// `ESC[200~` … `ESC[201~` and every embedded `ESC[200~` / `ESC[201~` is
/// removed first — a clipboard carrying `ESC[201~rm -rf /` would otherwise
/// close the region early and the tail would reach the shell as typed input.
/// Removal (rather than escaping) matches the upstream terminal convention:
/// there is no in-band way to quote a marker, so it is neutralized.
///
/// Without `bracketed` the bytes pass through unchanged — the receiving
/// program is reading raw input, so there is no region to break out of.
/// Empty input yields no bytes so callers never emit a bare
/// `ESC[200~ESC[201~`.
func wrapBracketedPaste(_ payload: Data, bracketed: Bool) -> Data {
if payload.isEmpty { return Data() }
guard bracketed else { return payload }
let markerLen = bracketedPasteStart.count
var out = [UInt8]()
out.reserveCapacity(payload.count + markerLen * 2)
out.append(contentsOf: bracketedPasteStart)
for byte in payload {
out.append(byte)
// Match on the *output* tail, not the input: dropping one marker can
// splice its neighbours into a fresh one (`ESC[20` + `ESC[200~` +
// `0~`), which a single input-side pass would let through. The
// doubled-length floor keeps the opening marker out of the window.
guard out.count >= markerLen * 2 else { continue }
let tail = out.suffix(markerLen)
if tail.elementsEqual(bracketedPasteStart) || tail.elementsEqual(bracketedPasteEnd) {
out.removeLast(markerLen)
}
}
out.append(contentsOf: bracketedPasteEnd)
return Data(out)
}
8 changes: 7 additions & 1 deletion mac/Sources/Roost/ShellEscape.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// `Ghostty.Shell.escapeCharacters` (vendored ghostty SHA pinned in
// third_party/ghostty/build.sh): backslash, space, and the shell
// metacharacters that would otherwise word-split or glob a dropped path at a
// raw shell prompt. Mirrors `crates/roost-linux/src/shell_escape.rs`
// raw shell prompt. Mirrors `crates/roost-ui-model/src/shell_escape.rs`
// (`shell_escape::escape`); the two implementations share unit-test vectors so
// they stay byte-identical.

Expand All @@ -22,6 +22,12 @@ enum ShellEscape {
/// non-ASCII codepoints (e.g. the U+202F narrow-no-break space in a macOS
/// screenshot filename) pass through unchanged, which modern shells handle as
/// UTF-8 literals.
///
/// This is a pure escaper: every input character reaches the output, so the
/// escaped string still names the same file. Control characters that no
/// filename may legitimately carry (newlines, ESC) are rejected earlier, at
/// the drop boundary in `TerminalView.dropContentString` — dropping them
/// here would let two distinct filenames collapse to the same PTY input.
static func escape(_ str: String) -> String {
var out = String()
out.reserveCapacity(str.count)
Expand Down
30 changes: 13 additions & 17 deletions mac/Sources/Roost/TerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1721,22 +1721,15 @@ final class TerminalView: NSView {
}
}

/// Wrap `payload` in `ESC[200~ … ESC[201~` when the shell has
/// DECSET 2004 active and hand it to the input callback. Shared by
/// `⌘V` (text + image paths) and middle-click PRIMARY paste so the
/// Frame `payload` through `wrapBracketedPaste` (which wraps in
/// `ESC[200~ … ESC[201~` and strips embedded markers when the shell
/// has DECSET 2004 active) and hand it to the input callback. Shared
/// by `⌘V` (text + image paths) and middle-click PRIMARY paste so the
/// three paste paths can't drift apart on bracketing or write
/// routing.
@MainActor
private func sendBracketedPaste(_ payload: Data) {
var bytes = payload
if bracketedPasteEnabled() {
// ESC [ 2 0 0 ~ … ESC [ 2 0 1 ~
var wrapped = Data([0x1b, 0x5b, 0x32, 0x30, 0x30, 0x7e])
wrapped.append(bytes)
wrapped.append(contentsOf: [0x1b, 0x5b, 0x32, 0x30, 0x31, 0x7e])
bytes = wrapped
}
onKey?(bytes)
onKey?(wrapBracketedPaste(payload, bracketed: bracketedPasteEnabled()))
}

// MARK: - Drag-and-drop (file / URL / text → bracketed paste)
Expand Down Expand Up @@ -1790,14 +1783,17 @@ final class TerminalView: NSView {
/// without a synthesised `NSDraggingInfo`; mirrors `drop_text` on GTK.
static func dropContentString(fileURLs: [URL], url: String?, string: String?) -> String? {
// De-duplicate by standardized path (Finder lists one file under several
// URL-shaped entries) and drop any path containing a newline — a `\n`
// would split the newline-join into bogus extra paths and, at a raw
// shell, execute everything after it. Such filenames are pathological;
// screenshots never have them.
// URL-shaped entries) and drop any path carrying a newline or an ESC — a
// `\n` would split the newline-join into bogus extra paths and, at a raw
// shell, execute everything after it; an ESC would smuggle a control
// sequence (e.g. a bracketed-paste marker) into the PTY. Rejecting, not
// stripping, so the escaped text always names the real file. Mirrors
// `drop_content::resolve`. Such filenames are pathological; screenshots
// never have them.
var seen = Set<String>()
let paths = fileURLs
.map { $0.standardizedFileURL.path }
.filter { !$0.contains(where: \.isNewline) }
.filter { !$0.contains(where: { $0.isNewline || $0 == "\u{1b}" }) }
.filter { seen.insert($0).inserted }
if !paths.isEmpty {
return paths.map { ShellEscape.escape($0) }.joined(separator: "\n")
Expand Down
Loading
Loading