Fix pasting bug.

This commit is contained in:
Greyson Parrelli
2026-08-27 11:11:49 -04:00
parent 553e1f5424
commit 1223499369
3 changed files with 132 additions and 7 deletions
+16
View File
@@ -196,6 +196,22 @@ pub fn build(b: *std.Build) void {
settings_tests.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt")); settings_tests.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt"));
test_step.dependOn(&b.addRunArtifact(settings_tests).step); test_step.dependOn(&b.addRunArtifact(settings_tests).step);
// Paste safety is its own root, and for a sharper reason than the rest:
// when it says no it says so silently — the terminal simply does not
// receive what you pasted — so the rule that decides has to be checkable
// without a display. It reaches libghostty-vt for the encoder and nothing
// else; the clipboard round trip around it is GTK's and stays in
// `Terminal.zig`.
const paste_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/paste.zig"),
.target = target,
.optimize = optimize,
}),
});
paste_tests.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt"));
test_step.dependOn(&b.addRunArtifact(paste_tests).step);
// The shortcut table is its own root for the same reason: chords are parsed // The shortcut table is its own root for the same reason: chords are parsed
// from text, written back out as text, and looked up by a key press, and // from text, written back out as text, and looked up by a key press, and
// all three are pure data. It reaches libghostty-vt for the key enum and // all three are pure data. It reaches libghostty-vt for the key enum and
+33 -7
View File
@@ -16,6 +16,7 @@ const gtk = @import("gtk");
const pango = @import("pango"); const pango = @import("pango");
const pangocairo = @import("pangocairo"); const pangocairo = @import("pangocairo");
const vt = @import("ghostty-vt"); const vt = @import("ghostty-vt");
const paste_rules = @import("paste.zig");
const keymap = @import("key.zig"); const keymap = @import("key.zig");
const theme = @import("theme.zig"); const theme = @import("theme.zig");
@@ -849,20 +850,45 @@ fn onPasteReady(
/// Type text into the child as though it had been pasted, bracketing it if /// Type text into the child as though it had been pasted, bracketing it if
/// the program asked for that. /// the program asked for that.
pub fn paste(self: *Terminal, text: []const u8) void { pub fn paste(self: *Terminal, text: []const u8) void {
if (text.len == 0) return;
const opts: vt.input.PasteOptions = .fromTerminal(&self.session.term); const opts: vt.input.PasteOptions = .fromTerminal(&self.session.term);
// Refuse pastes containing control characters that would execute on // Whether this is safe to type into the child turns on bracketing, and
// arrival (a newline in unbracketed mode runs the command immediately). // this used to ignore that — which refused nearly every real paste, since a
if (!vt.input.isSafePaste(text)) { // bracketed program is the normal case and multi-line text is the normal
// thing to paste. See `paste.zig` for the rule and its tests.
if (paste_rules.isUnsafe(text, opts.bracketed)) {
std.log.warn("refusing unsafe paste", .{}); std.log.warn("refusing unsafe paste", .{});
return; return;
} }
const parts = vt.input.encodePaste(text, opts) catch |err| { // Encoding rewrites bytes in place — the control characters xterm turns
std.log.warn("paste encode failed: {s}", .{@errorName(err)}); // into spaces, and newlines into carriage returns when unbracketed — so it
return; // asks for a mutable copy when the text holds any of them. Most pastes hold
// none and encode straight out of the clipboard's own buffer, so the copy
// is made only when the borrowed attempt says it needs one. Treating that
// request as a failure, as this did, dropped every paste carrying an escape
// or a tab-completion artifact.
var owned: ?[]u8 = null;
defer if (owned) |buf| self.alloc.free(buf);
const parts = vt.input.encodePaste(text, opts) catch |err| switch (err) {
error.MutableRequired => parts: {
const buf = self.alloc.dupe(u8, text) catch |e| {
std.log.warn("paste copy failed: {s}", .{@errorName(e)});
return;
};
owned = buf;
break :parts vt.input.encodePaste(buf, opts);
},
}; };
for (parts) |part| self.session.write(part);
// Written before `owned` is freed: the pty write is a synchronous
// `write(2)` loop, so nothing holds on to these slices afterwards.
for (parts) |part| {
if (part.len > 0) self.session.write(part);
}
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
+83
View File
@@ -0,0 +1,83 @@
//! Whether a paste is safe to hand to the child, and how to encode it.
//!
//! Its own file, off GTK, for the reason the other roots here are: the
//! decision is text in, verdict out, and it is the half of pasting that can be
//! wrong without anything looking wrong — a refused paste is silent, and the
//! terminal it was aimed at just sits there. The clipboard round trip above it
//! is GTK's and wants a display; this part does not.
const std = @import("std");
const vt = @import("ghostty-vt");
/// Whether text must be refused rather than typed into the child.
///
/// The answer turns on bracketed paste, and getting that wrong in the
/// permissive direction is what makes this worth a function. Bracketed, the
/// text arrives fenced: the program reads it as data, so a newline in it is a
/// newline and not an Enter that runs whatever came before. The only thing left
/// to refuse is a closing fence *inside* the text, which would end the bracket
/// early and let everything after it run as though typed.
///
/// Unbracketed there is no fence, so a newline really is an Enter and
/// libghostty's own test — no newline, no stray fence — is the one that
/// applies.
pub fn isUnsafe(text: []const u8, bracketed: bool) bool {
if (bracketed) return std.mem.indexOf(u8, text, end_fence) != null;
return !vt.input.isSafePaste(text);
}
/// The sequence that closes a bracketed paste. Never trusted inside one.
const end_fence = "\x1b[201~";
test "bracketed pastes carry newlines, which is the point of bracketing" {
try std.testing.expect(!isUnsafe("one\ntwo\nthree", true));
try std.testing.expect(!isUnsafe("cd /tmp && ls\n", true));
}
test "a closing fence is refused even bracketed" {
try std.testing.expect(isUnsafe("ls\x1b[201~rm -rf /\n", true));
}
test "unbracketed, a newline is an Enter and is refused" {
try std.testing.expect(isUnsafe("one\ntwo", false));
try std.testing.expect(isUnsafe("ls\x1b[201~x", false));
}
test "plain text pastes either way" {
for ([_]bool{ true, false }) |bracketed| {
try std.testing.expect(!isUnsafe("hello", bracketed));
try std.testing.expect(!isUnsafe("", bracketed));
}
}
test "text needing no rewriting encodes without a copy" {
const parts = try vt.input.encodePaste(@as([]const u8, "hello"), .{ .bracketed = true });
try std.testing.expectEqualStrings("\x1b[200~", parts[0]);
try std.testing.expectEqualStrings("hello", parts[1]);
try std.testing.expectEqualStrings("\x1b[201~", parts[2]);
}
test "an escape in the text asks for a mutable copy, and encodes once given one" {
// The case the terminal used to drop: encoding rewrites these bytes in
// place, so borrowed text is declined rather than mangled, and the caller
// is expected to copy and come back.
const text = "before\x1bafter";
try std.testing.expectError(
error.MutableRequired,
vt.input.encodePaste(@as([]const u8, text), .{ .bracketed = true }),
);
const buf = try std.testing.allocator.dupe(u8, text);
defer std.testing.allocator.free(buf);
const parts = vt.input.encodePaste(buf, .{ .bracketed = true });
try std.testing.expectEqualStrings("before after", parts[1]);
}
test "unbracketed newlines become carriage returns once copied" {
const buf = try std.testing.allocator.dupe(u8, "one\ntwo");
defer std.testing.allocator.free(buf);
const parts = vt.input.encodePaste(buf, .{ .bracketed = false });
try std.testing.expectEqualStrings("", parts[0]);
try std.testing.expectEqualStrings("one\rtwo", parts[1]);
try std.testing.expectEqualStrings("", parts[2]);
}