diff --git a/paste.go b/paste.go new file mode 100644 index 0000000..2394119 --- /dev/null +++ b/paste.go @@ -0,0 +1,82 @@ +package libghostty + +// Paste utilities — validate and encode paste data for terminal input. +// Wraps the C APIs from paste.h. + +/* +#include +*/ +import "C" + +import "unsafe" + +// PasteIsSafe reports whether data is safe to paste into a terminal. +// +// Ghostty's safety check is intentionally conservative: data containing +// newlines or bracketed-paste end markers is considered unsafe because it can +// inject commands into interactive programs. Empty data is safe. +func PasteIsSafe(data []byte) bool { + if len(data) == 0 { + return true + } + + return bool(C.ghostty_paste_is_safe( + (*C.char)(unsafe.Pointer(&data[0])), + C.size_t(len(data)), + )) +} + +// PasteEncode prepares data for writing to a terminal pty. +// +// The encoder applies Ghostty's terminal-input paste rules: unsafe control +// bytes are replaced with spaces, bracketed paste markers are added when +// bracketed is true, and newlines become carriage returns when bracketed is +// false. The input slice is copied before calling into Ghostty because the C +// encoder mutates its data buffer in place. +func PasteEncode(data []byte, bracketed bool) ([]byte, error) { + if len(data) == 0 { + return nil, nil + } + + // The current encoder only grows output by adding bracketed-paste markers, + // but intentionally treat buffer sizing as an implementation detail of the C + // API. Starting with modest slack keeps the common path allocation-light + // while still honoring GHOSTTY_OUT_OF_SPACE below if the encoder changes. + in := append([]byte(nil), data...) + buf := make([]byte, len(data)+32) + out, required, err := pasteEncodeInto(in, bracketed, buf) + if err == nil { + return out, nil + } + + ge, ok := err.(*Error) + if !ok || ge.Result != ResultOutOfSpace || required <= 0 { + return nil, err + } + + in = append([]byte(nil), data...) + buf = make([]byte, required) + out, _, err = pasteEncodeInto(in, bracketed, buf) + return out, err +} + +func pasteEncodeInto( + data []byte, + bracketed bool, + buf []byte, +) ([]byte, int, error) { + var written C.size_t + result := C.ghostty_paste_encode( + (*C.char)(unsafe.Pointer(&data[0])), + C.size_t(len(data)), + C.bool(bracketed), + (*C.char)(unsafe.Pointer(&buf[0])), + C.size_t(len(buf)), + &written, + ) + if result == C.GHOSTTY_SUCCESS { + return buf[:int(written)], int(written), nil + } + + return nil, int(written), &Error{Result: Result(result)} +} diff --git a/paste_test.go b/paste_test.go new file mode 100644 index 0000000..1a3c565 --- /dev/null +++ b/paste_test.go @@ -0,0 +1,62 @@ +package libghostty + +import "testing" + +func TestPasteIsSafe(t *testing.T) { + tests := []struct { + name string + data string + want bool + }{ + {name: "empty", data: "", want: true}, + {name: "plain", data: "hello", want: true}, + {name: "newline", data: "hello\nworld", want: false}, + {name: "bracketed paste end", data: "hello\x1b[201~world", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := PasteIsSafe([]byte(tt.data)); got != tt.want { + t.Fatalf("PasteIsSafe(%q) = %v, want %v", tt.data, got, tt.want) + } + }) + } +} + +func TestPasteEncodeLegacy(t *testing.T) { + in := []byte("hello\nworld\x1b!") + + out, err := PasteEncode(in, false) + if err != nil { + t.Fatal(err) + } + + if string(out) != "hello\rworld !" { + t.Fatalf("PasteEncode legacy = %q, want %q", out, "hello\rworld !") + } + if string(in) != "hello\nworld\x1b!" { + t.Fatalf("PasteEncode mutated input to %q", in) + } +} + +func TestPasteEncodeBracketed(t *testing.T) { + out, err := PasteEncode([]byte("hello\nworld"), true) + if err != nil { + t.Fatal(err) + } + + want := "\x1b[200~hello\nworld\x1b[201~" + if string(out) != want { + t.Fatalf("PasteEncode bracketed = %q, want %q", out, want) + } +} + +func TestPasteEncodeEmpty(t *testing.T) { + out, err := PasteEncode(nil, false) + if err != nil { + t.Fatal(err) + } + if out != nil { + t.Fatalf("PasteEncode empty = %q, want nil", out) + } +}