Fix pasting bug.
This commit is contained in:
@@ -196,6 +196,22 @@ pub fn build(b: *std.Build) void {
|
||||
settings_tests.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt"));
|
||||
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
|
||||
// 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
|
||||
|
||||
+32
-6
@@ -16,6 +16,7 @@ const gtk = @import("gtk");
|
||||
const pango = @import("pango");
|
||||
const pangocairo = @import("pangocairo");
|
||||
const vt = @import("ghostty-vt");
|
||||
const paste_rules = @import("paste.zig");
|
||||
|
||||
const keymap = @import("key.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
|
||||
/// the program asked for that.
|
||||
pub fn paste(self: *Terminal, text: []const u8) void {
|
||||
if (text.len == 0) return;
|
||||
|
||||
const opts: vt.input.PasteOptions = .fromTerminal(&self.session.term);
|
||||
|
||||
// Refuse pastes containing control characters that would execute on
|
||||
// arrival (a newline in unbracketed mode runs the command immediately).
|
||||
if (!vt.input.isSafePaste(text)) {
|
||||
// Whether this is safe to type into the child turns on bracketing, and
|
||||
// this used to ignore that — which refused nearly every real paste, since a
|
||||
// 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", .{});
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = vt.input.encodePaste(text, opts) catch |err| {
|
||||
std.log.warn("paste encode failed: {s}", .{@errorName(err)});
|
||||
// Encoding rewrites bytes in place — the control characters xterm turns
|
||||
// into spaces, and newlines into carriage returns when unbracketed — so it
|
||||
// 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;
|
||||
};
|
||||
for (parts) |part| self.session.write(part);
|
||||
owned = buf;
|
||||
break :parts vt.input.encodePaste(buf, opts);
|
||||
},
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user