diff --git a/crates/roost-iced/src/app/interactions.rs b/crates/roost-iced/src/app/interactions.rs index 226191ac..5c91723f 100644 --- a/crates/roost-iced/src/app/interactions.rs +++ b/crates/roost-iced/src/app/interactions.rs @@ -1013,14 +1013,7 @@ pub(super) fn paste_bytes(terminal: &Terminal, text: Option<&str>) -> Vec { 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 { @@ -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")] diff --git a/crates/roost-linux/src/terminal_view.rs b/crates/roost-linux/src/terminal_view.rs index df05e422..62e400b2 100644 --- a/crates/roost-linux/src/terminal_view.rs +++ b/crates/roost-linux/src/terminal_view.rs @@ -2448,8 +2448,9 @@ fn selection_text(state: &Rc>) -> Option { } } -/// 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 @@ -2467,16 +2468,7 @@ fn paste_text_into(state: &Rc>, 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. diff --git a/crates/roost-ui-model/src/bracketed_paste.rs b/crates/roost-ui-model/src/bracketed_paste.rs new file mode 100644 index 00000000..e4476aa4 --- /dev/null +++ b/crates/roost-ui-model/src/bracketed_paste.rs @@ -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 { + 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() + ); + } +} diff --git a/crates/roost-ui-model/src/drop_content.rs b/crates/roost-ui-model/src/drop_content.rs index 57461c25..b26540d5 100644 --- a/crates/roost-ui-model/src/drop_content.rs +++ b/crates/roost-ui-model/src/drop_content.rs @@ -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. @@ -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::>(); if !escaped.is_empty() { @@ -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() { diff --git a/crates/roost-ui-model/src/lib.rs b/crates/roost-ui-model/src/lib.rs index b5104ffc..561893e7 100644 --- a/crates/roost-ui-model/src/lib.rs +++ b/crates/roost-ui-model/src/lib.rs @@ -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; diff --git a/crates/roost-ui-model/src/shell_escape.rs b/crates/roost-ui-model/src/shell_escape.rs index 95e242d7..51f49523 100644 --- a/crates/roost-ui-model/src/shell_escape.rs +++ b/crates/roost-ui-model/src/shell_escape.rs @@ -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() { @@ -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"); diff --git a/mac/Sources/Roost/BracketedPaste.swift b/mac/Sources/Roost/BracketedPaste.swift new file mode 100644 index 00000000..5a63a78e --- /dev/null +++ b/mac/Sources/Roost/BracketedPaste.swift @@ -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) +} diff --git a/mac/Sources/Roost/ShellEscape.swift b/mac/Sources/Roost/ShellEscape.swift index 3e27f2e6..d941544d 100644 --- a/mac/Sources/Roost/ShellEscape.swift +++ b/mac/Sources/Roost/ShellEscape.swift @@ -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. @@ -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) diff --git a/mac/Sources/Roost/TerminalView.swift b/mac/Sources/Roost/TerminalView.swift index 08fdcbd0..9a1b5d2a 100644 --- a/mac/Sources/Roost/TerminalView.swift +++ b/mac/Sources/Roost/TerminalView.swift @@ -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) @@ -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() 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") diff --git a/mac/Tests/RoostTests/BracketedPasteTests.swift b/mac/Tests/RoostTests/BracketedPasteTests.swift new file mode 100644 index 00000000..555a7b39 --- /dev/null +++ b/mac/Tests/RoostTests/BracketedPasteTests.swift @@ -0,0 +1,65 @@ +// Bracketed-paste framing tests, Swift companion to the shared suite in +// `crates/roost-ui-model/src/bracketed_paste.rs::tests`. The vectors are shared +// verbatim with the Rust side so the three UIs stay byte-identical on the +// sanitize + wrap boundary (the cross-UI parity the north star asks for). +// +// XCTest, not swift-testing, for the same reason `ShellEscapeTests` is: a swarm +// of trivially fast value-checks in the swift-testing run aborts +// `swiftpm-testing-helper` mid-run under Xcode 26.x (SIGABRT with no failing +// test — observed on CI run 30796756743 for this suite). XCTest runs in a +// separate harness and stays green. + +import Foundation +import XCTest + +@testable import Roost + +private func wrapped(_ text: String, _ bracketed: Bool) -> [UInt8] { + Array(wrapBracketedPaste(Data(text.utf8), bracketed: bracketed)) +} + +private func bytes(_ text: String) -> [UInt8] { Array(text.utf8) } + +final class BracketedPasteTests: XCTestCase { + func testEmptyTextYieldsNoBytes() { + XCTAssertTrue(wrapped("", false).isEmpty) + XCTAssertTrue(wrapped("", true).isEmpty) + } + + func testPassthroughWithoutBracketedMode() { + XCTAssertEqual(wrapped("hello\n", false), bytes("hello\n")) + // No 2004, no region to break: the bytes are delivered verbatim. + XCTAssertEqual(wrapped("a\u{1b}[201~b", false), bytes("a\u{1b}[201~b")) + } + + func testPlainPayloadIsWrappedOnce() { + XCTAssertEqual(wrapped("hello\n", true), bytes("\u{1b}[200~hello\n\u{1b}[201~")) + } + + func testEmbeddedEndMarkerIsRemoved() { + XCTAssertEqual( + wrapped("\u{1b}[201~rm -rf /\n", true), + bytes("\u{1b}[200~rm -rf /\n\u{1b}[201~") + ) + } + + func testEmbeddedStartMarkerIsRemoved() { + XCTAssertEqual(wrapped("a\u{1b}[200~b", true), bytes("\u{1b}[200~ab\u{1b}[201~")) + } + + /// Removal is re-checked against the output, so halves left adjacent by an + /// earlier removal cannot re-form a marker. + func testRemovalCannotSpliceANewMarker() { + XCTAssertEqual(wrapped("x\u{1b}[20\u{1b}[200~0~y", true), bytes("\u{1b}[200~xy\u{1b}[201~")) + } + + /// Only the contiguous six-byte sequence is a marker; a truncated prefix is + /// ordinary payload. + func testPartialMarkerIsPreserved() { + XCTAssertEqual(wrapped("\u{1b}[201", true), bytes("\u{1b}[200~\u{1b}[201\u{1b}[201~")) + } + + func testUtf8AroundRemovalsIsPreserved() { + XCTAssertEqual(wrapped("图\u{1b}[201~片", true), bytes("\u{1b}[200~图片\u{1b}[201~")) + } +} diff --git a/mac/Tests/RoostTests/ShellEscapeTests.swift b/mac/Tests/RoostTests/ShellEscapeTests.swift index 15b0f685..3dbbb543 100644 --- a/mac/Tests/RoostTests/ShellEscapeTests.swift +++ b/mac/Tests/RoostTests/ShellEscapeTests.swift @@ -1,5 +1,5 @@ // Shell-escape + drop-payload-resolver tests, Swift companion to the GTK suite -// in `crates/roost-linux/src/shell_escape.rs::tests`. The escape vectors are +// in `crates/roost-ui-model/src/shell_escape.rs::tests`. The escape vectors are // shared verbatim with the Rust side so the two drag-and-drop implementations // stay byte-identical (the cross-UI parity the north star asks for). // @@ -48,6 +48,18 @@ final class ShellEscapeTests: XCTestCase { // "\ " -> escape the backslash, then the space. XCTAssertEqual(ShellEscape.escape("\\ "), "\\\\\\ ") } + + /// Shared verbatim with the Rust `escape_byte_passes_through` 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 `TerminalView.dropContentString`. + func testEscapeBytePassesThrough() { + XCTAssertEqual( + ShellEscape.escape("/tmp/ev\u{1B}[201~il.png"), + "/tmp/ev\u{1B}\\[201~il.png" + ) + XCTAssertEqual(ShellEscape.escape("\u{1B}"), "\u{1B}") + } } @MainActor @@ -112,6 +124,24 @@ final class DropContentResolverTests: XCTestCase { ) } + /// Shared with the Rust `escape_bearing_paths_are_rejected` vector: an ESC + /// in a filename is rejected at the drop boundary rather than stripped by + /// the escaper. + func testControlBearingPathIsDropped() { + XCTAssertNil( + TerminalView.dropContentString( + fileURLs: [fileURL("/tmp/ev\u{1B}[201~il.png")], url: nil, string: nil + ) + ) + XCTAssertEqual( + TerminalView.dropContentString( + fileURLs: [fileURL("/tmp/ev\u{1B}[201~il.png"), fileURL("/tmp/ok.png")], + url: nil, string: nil + ), + "/tmp/ok.png" + ) + } + func testMultilineTextDropIsPreserved() { // Plain text legitimately keeps its newlines (multi-line text drop). XCTAssertEqual(