diff --git a/build.zig b/build.zig index 4185698..3f17791 100644 --- a/build.zig +++ b/build.zig @@ -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 diff --git a/src/Terminal.zig b/src/Terminal.zig index 3cc174d..34d8b36 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -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)}); - return; + // 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; + }; + 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); + } } // ------------------------------------------------------------------------- diff --git a/src/paste.zig b/src/paste.zig new file mode 100644 index 0000000..d997c21 --- /dev/null +++ b/src/paste.zig @@ -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]); +}