mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-15 16:26:08 +03:00
Merge pull request #812 from mitchellh/feature/clipboard-safety
GTK Feature: Clipboard Safety
This commit is contained in:
@ -142,6 +142,7 @@ const DerivedConfig = struct {
|
|||||||
clipboard_read: bool,
|
clipboard_read: bool,
|
||||||
clipboard_write: bool,
|
clipboard_write: bool,
|
||||||
clipboard_trim_trailing_spaces: bool,
|
clipboard_trim_trailing_spaces: bool,
|
||||||
|
clipboard_paste_protection: bool,
|
||||||
copy_on_select: configpkg.CopyOnSelect,
|
copy_on_select: configpkg.CopyOnSelect,
|
||||||
confirm_close_surface: bool,
|
confirm_close_surface: bool,
|
||||||
mouse_interval: u64,
|
mouse_interval: u64,
|
||||||
@ -165,6 +166,7 @@ const DerivedConfig = struct {
|
|||||||
.clipboard_read = config.@"clipboard-read",
|
.clipboard_read = config.@"clipboard-read",
|
||||||
.clipboard_write = config.@"clipboard-write",
|
.clipboard_write = config.@"clipboard-write",
|
||||||
.clipboard_trim_trailing_spaces = config.@"clipboard-trim-trailing-spaces",
|
.clipboard_trim_trailing_spaces = config.@"clipboard-trim-trailing-spaces",
|
||||||
|
.clipboard_paste_protection = config.@"clipboard-paste-protection",
|
||||||
.copy_on_select = config.@"copy-on-select",
|
.copy_on_select = config.@"copy-on-select",
|
||||||
.confirm_close_surface = config.@"confirm-close-surface",
|
.confirm_close_surface = config.@"confirm-close-surface",
|
||||||
.mouse_interval = config.@"click-repeat-interval" * 1_000_000, // 500ms
|
.mouse_interval = config.@"click-repeat-interval" * 1_000_000, // 500ms
|
||||||
@ -1163,7 +1165,7 @@ pub fn keyCallback(
|
|||||||
/// if bracketed mode is on this will do a bracketed paste. Otherwise,
|
/// if bracketed mode is on this will do a bracketed paste. Otherwise,
|
||||||
/// this will filter newlines to '\r'.
|
/// this will filter newlines to '\r'.
|
||||||
pub fn textCallback(self: *Surface, text: []const u8) !void {
|
pub fn textCallback(self: *Surface, text: []const u8) !void {
|
||||||
try self.completeClipboardPaste(text);
|
try self.completeClipboardPaste(text, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn focusCallback(self: *Surface, focused: bool) !void {
|
pub fn focusCallback(self: *Surface, focused: bool) !void {
|
||||||
@ -2453,13 +2455,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
|||||||
/// Call this to complete a clipboard request sent to apprt. This should
|
/// Call this to complete a clipboard request sent to apprt. This should
|
||||||
/// only be called once for each request. The data is immediately copied so
|
/// only be called once for each request. The data is immediately copied so
|
||||||
/// it is safe to free the data after this call.
|
/// it is safe to free the data after this call.
|
||||||
|
///
|
||||||
|
/// If "allow_unsafe" is false, then the data is checked for "safety" prior.
|
||||||
|
/// If unsafe data is detected, this will return error.UnsafePaste. Unsafe
|
||||||
|
/// data is defined as data that contains newlines, though this definition
|
||||||
|
/// may change later to detect other scenarios.
|
||||||
pub fn completeClipboardRequest(
|
pub fn completeClipboardRequest(
|
||||||
self: *Surface,
|
self: *Surface,
|
||||||
req: apprt.ClipboardRequest,
|
req: apprt.ClipboardRequest,
|
||||||
data: []const u8,
|
data: []const u8,
|
||||||
|
allow_unsafe: bool,
|
||||||
) !void {
|
) !void {
|
||||||
switch (req) {
|
switch (req) {
|
||||||
.paste => try self.completeClipboardPaste(data),
|
.paste => try self.completeClipboardPaste(data, allow_unsafe),
|
||||||
.osc_52 => |kind| try self.completeClipboardReadOSC52(data, kind),
|
.osc_52 => |kind| try self.completeClipboardReadOSC52(data, kind),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2485,9 +2493,24 @@ fn startClipboardRequest(
|
|||||||
try self.rt_surface.clipboardRequest(loc, req);
|
try self.rt_surface.clipboardRequest(loc, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn completeClipboardPaste(self: *Surface, data: []const u8) !void {
|
fn completeClipboardPaste(
|
||||||
|
self: *Surface,
|
||||||
|
data: []const u8,
|
||||||
|
allow_unsafe: bool,
|
||||||
|
) !void {
|
||||||
if (data.len == 0) return;
|
if (data.len == 0) return;
|
||||||
|
|
||||||
|
// If we have paste protection enabled, we detect unsafe pastes and return
|
||||||
|
// an error. The error approach allows apprt to attempt to complete the paste
|
||||||
|
// before falling back to requesting confirmation.
|
||||||
|
if (self.config.clipboard_paste_protection and
|
||||||
|
!allow_unsafe and
|
||||||
|
!terminal.isSafePaste(data))
|
||||||
|
{
|
||||||
|
log.info("potentially unsafe paste detected, rejecting until confirmation", .{});
|
||||||
|
return error.UnsafePaste;
|
||||||
|
}
|
||||||
|
|
||||||
const bracketed = bracketed: {
|
const bracketed = bracketed: {
|
||||||
self.renderer_state.mutex.lock();
|
self.renderer_state.mutex.lock();
|
||||||
defer self.renderer_state.mutex.unlock();
|
defer self.renderer_state.mutex.unlock();
|
||||||
|
@ -1360,7 +1360,8 @@ pub const CAPI = struct {
|
|||||||
|
|
||||||
if (str_len == 0) return;
|
if (str_len == 0) return;
|
||||||
const str = str_ptr[0..str_len];
|
const str = str_ptr[0..str_len];
|
||||||
ptr.core_surface.completeClipboardRequest(state.*, str) catch |err| {
|
// TODO: Support sanaization for MacOS (force: false)
|
||||||
|
ptr.core_surface.completeClipboardRequest(state.*, str, true) catch |err| {
|
||||||
log.err("error completing clipboard request err={}", .{err});
|
log.err("error completing clipboard request err={}", .{err});
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
@ -588,8 +588,9 @@ pub const Surface = struct {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Complete our request
|
// Complete our request. We always allow unsafe because we don't
|
||||||
try self.core_surface.completeClipboardRequest(state, str);
|
// want to deal with user confirmation in this runtime.
|
||||||
|
try self.core_surface.completeClipboardRequest(state, str, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the clipboard.
|
/// Set the clipboard.
|
||||||
|
@ -24,6 +24,7 @@ const build_options = @import("build_options");
|
|||||||
const Surface = @import("Surface.zig");
|
const Surface = @import("Surface.zig");
|
||||||
const Window = @import("Window.zig");
|
const Window = @import("Window.zig");
|
||||||
const ConfigErrorsWindow = @import("ConfigErrorsWindow.zig");
|
const ConfigErrorsWindow = @import("ConfigErrorsWindow.zig");
|
||||||
|
const UnsafePasteWindow = @import("UnsafePasteWindow.zig");
|
||||||
const c = @import("c.zig");
|
const c = @import("c.zig");
|
||||||
const inspector = @import("inspector.zig");
|
const inspector = @import("inspector.zig");
|
||||||
const key = @import("key.zig");
|
const key = @import("key.zig");
|
||||||
@ -47,6 +48,9 @@ menu: ?*c.GMenu = null,
|
|||||||
/// The configuration errors window, if it is currently open.
|
/// The configuration errors window, if it is currently open.
|
||||||
config_errors_window: ?*ConfigErrorsWindow = null,
|
config_errors_window: ?*ConfigErrorsWindow = null,
|
||||||
|
|
||||||
|
/// The unsafe paste window, if it is currently open.
|
||||||
|
unsafe_paste_window: ?*UnsafePasteWindow = null,
|
||||||
|
|
||||||
/// This is set to false when the main loop should exit.
|
/// This is set to false when the main loop should exit.
|
||||||
running: bool = true,
|
running: bool = true,
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@ const CoreSurface = @import("../../Surface.zig");
|
|||||||
|
|
||||||
const App = @import("App.zig");
|
const App = @import("App.zig");
|
||||||
const Window = @import("Window.zig");
|
const Window = @import("Window.zig");
|
||||||
|
const UnsafePasteWindow = @import("UnsafePasteWindow.zig");
|
||||||
const inspector = @import("inspector.zig");
|
const inspector = @import("inspector.zig");
|
||||||
const gtk_key = @import("key.zig");
|
const gtk_key = @import("key.zig");
|
||||||
const c = @import("c.zig");
|
const c = @import("c.zig");
|
||||||
@ -546,10 +547,27 @@ fn gtkClipboardRead(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
defer c.g_free(cstr);
|
defer c.g_free(cstr);
|
||||||
|
|
||||||
const str = std.mem.sliceTo(cstr, 0);
|
const str = std.mem.sliceTo(cstr, 0);
|
||||||
self.core_surface.completeClipboardRequest(req.state, str) catch |err| {
|
|
||||||
log.err("failed to complete clipboard request err={}", .{err});
|
self.core_surface.completeClipboardRequest(
|
||||||
|
req.state,
|
||||||
|
str,
|
||||||
|
false,
|
||||||
|
) catch |err| switch (err) {
|
||||||
|
error.UnsafePaste => {
|
||||||
|
// Create a dialog and ask the user if they want to paste anyway.
|
||||||
|
UnsafePasteWindow.create(
|
||||||
|
self.app,
|
||||||
|
str,
|
||||||
|
self.core_surface,
|
||||||
|
req.state,
|
||||||
|
) catch |window_err| {
|
||||||
|
log.err("failed to create unsafe paste window err={}", .{window_err});
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
|
||||||
|
else => log.err("failed to complete clipboard request err={}", .{err}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -559,7 +577,6 @@ fn getClipboard(widget: *c.GtkWidget, clipboard: apprt.Clipboard) ?*c.GdkClipboa
|
|||||||
.selection => c.gtk_widget_get_primary_clipboard(widget),
|
.selection => c.gtk_widget_get_primary_clipboard(widget),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getCursorPos(self: *const Surface) !apprt.CursorPos {
|
pub fn getCursorPos(self: *const Surface) !apprt.CursorPos {
|
||||||
return self.cursor_pos;
|
return self.cursor_pos;
|
||||||
}
|
}
|
||||||
|
219
src/apprt/gtk/UnsafePasteWindow.zig
Normal file
219
src/apprt/gtk/UnsafePasteWindow.zig
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
/// Unsafe Paste Window
|
||||||
|
const UnsafePaste = @This();
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
|
const CoreSurface = @import("../../Surface.zig");
|
||||||
|
const ClipboardRequest = @import("../structs.zig").ClipboardRequest;
|
||||||
|
const App = @import("App.zig");
|
||||||
|
const View = @import("View.zig");
|
||||||
|
const c = @import("c.zig");
|
||||||
|
|
||||||
|
const log = std.log.scoped(.gtk);
|
||||||
|
|
||||||
|
app: *App,
|
||||||
|
window: *c.GtkWindow,
|
||||||
|
view: PrimaryView,
|
||||||
|
|
||||||
|
data: []u8,
|
||||||
|
core_surface: CoreSurface,
|
||||||
|
pending_req: ClipboardRequest,
|
||||||
|
|
||||||
|
pub fn create(
|
||||||
|
app: *App,
|
||||||
|
data: []const u8,
|
||||||
|
core_surface: CoreSurface,
|
||||||
|
request: ClipboardRequest,
|
||||||
|
) !void {
|
||||||
|
if (app.unsafe_paste_window != null) return error.WindowAlreadyExists;
|
||||||
|
|
||||||
|
const alloc = app.core_app.alloc;
|
||||||
|
const self = try alloc.create(UnsafePaste);
|
||||||
|
errdefer alloc.destroy(self);
|
||||||
|
try self.init(
|
||||||
|
app,
|
||||||
|
data,
|
||||||
|
core_surface,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
|
||||||
|
app.unsafe_paste_window = self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Not public because this should be called by the GTK lifecycle.
|
||||||
|
fn destroy(self: *UnsafePaste) void {
|
||||||
|
const alloc = self.app.core_app.alloc;
|
||||||
|
self.app.unsafe_paste_window = null;
|
||||||
|
alloc.destroy(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(
|
||||||
|
self: *UnsafePaste,
|
||||||
|
app: *App,
|
||||||
|
data: []const u8,
|
||||||
|
core_surface: CoreSurface,
|
||||||
|
request: ClipboardRequest,
|
||||||
|
) !void {
|
||||||
|
// Create the window
|
||||||
|
const window = c.gtk_window_new();
|
||||||
|
const gtk_window: *c.GtkWindow = @ptrCast(window);
|
||||||
|
errdefer c.gtk_window_destroy(gtk_window);
|
||||||
|
c.gtk_window_set_title(gtk_window, "Warning: Potentially Unsafe Paste");
|
||||||
|
c.gtk_window_set_default_size(gtk_window, 550, 275);
|
||||||
|
c.gtk_window_set_resizable(gtk_window, 0);
|
||||||
|
_ = c.g_signal_connect_data(
|
||||||
|
window,
|
||||||
|
"destroy",
|
||||||
|
c.G_CALLBACK(>kDestroy),
|
||||||
|
self,
|
||||||
|
null,
|
||||||
|
c.G_CONNECT_DEFAULT,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set some state
|
||||||
|
self.* = .{
|
||||||
|
.app = app,
|
||||||
|
.window = gtk_window,
|
||||||
|
.view = undefined,
|
||||||
|
.data = try app.core_app.alloc.dupe(u8, data),
|
||||||
|
.core_surface = core_surface,
|
||||||
|
.pending_req = request,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show the window
|
||||||
|
const view = try PrimaryView.init(self, data);
|
||||||
|
self.view = view;
|
||||||
|
c.gtk_window_set_child(@ptrCast(window), view.root);
|
||||||
|
c.gtk_widget_show(window);
|
||||||
|
|
||||||
|
// Block the main window from input.
|
||||||
|
// This will auto-revert when the window is closed.
|
||||||
|
c.gtk_window_set_modal(gtk_window, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gtkDestroy(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void {
|
||||||
|
const self: *UnsafePaste = @ptrCast(@alignCast(ud orelse return));
|
||||||
|
self.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const PrimaryView = struct {
|
||||||
|
root: *c.GtkWidget,
|
||||||
|
text: *c.GtkTextView,
|
||||||
|
|
||||||
|
pub fn init(root: *UnsafePaste, data: []const u8) !PrimaryView {
|
||||||
|
// All our widgets
|
||||||
|
const label = c.gtk_label_new(
|
||||||
|
"Pasting this text into the terminal may be dangerous as " ++
|
||||||
|
"it looks like some commands may be executed.",
|
||||||
|
);
|
||||||
|
const buf = unsafeBuffer(data);
|
||||||
|
defer c.g_object_unref(buf);
|
||||||
|
const buttons = try ButtonsView.init(root);
|
||||||
|
const text_scroll = c.gtk_scrolled_window_new();
|
||||||
|
errdefer c.g_object_unref(text_scroll);
|
||||||
|
const text = c.gtk_text_view_new_with_buffer(buf);
|
||||||
|
errdefer c.g_object_unref(text);
|
||||||
|
c.gtk_scrolled_window_set_child(@ptrCast(text_scroll), text);
|
||||||
|
|
||||||
|
// Create our view
|
||||||
|
const view = try View.init(&.{
|
||||||
|
.{ .name = "label", .widget = label },
|
||||||
|
.{ .name = "text", .widget = text_scroll },
|
||||||
|
.{ .name = "buttons", .widget = buttons.root },
|
||||||
|
}, &vfl);
|
||||||
|
errdefer view.deinit();
|
||||||
|
|
||||||
|
// We can do additional settings once the layout is setup
|
||||||
|
c.gtk_label_set_wrap(@ptrCast(label), 1);
|
||||||
|
c.gtk_text_view_set_editable(@ptrCast(text), 0);
|
||||||
|
c.gtk_text_view_set_cursor_visible(@ptrCast(text), 0);
|
||||||
|
c.gtk_text_view_set_top_margin(@ptrCast(text), 8);
|
||||||
|
c.gtk_text_view_set_bottom_margin(@ptrCast(text), 8);
|
||||||
|
c.gtk_text_view_set_left_margin(@ptrCast(text), 8);
|
||||||
|
c.gtk_text_view_set_right_margin(@ptrCast(text), 8);
|
||||||
|
|
||||||
|
return .{ .root = view.root, .text = @ptrCast(text) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the GtkTextBuffer for the data that was unsafe.
|
||||||
|
fn unsafeBuffer(data: []const u8) *c.GtkTextBuffer {
|
||||||
|
const buf = c.gtk_text_buffer_new(null);
|
||||||
|
errdefer c.g_object_unref(buf);
|
||||||
|
|
||||||
|
c.gtk_text_buffer_insert_at_cursor(buf, data.ptr, @intCast(data.len));
|
||||||
|
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vfl = [_][*:0]const u8{
|
||||||
|
"H:|-8-[label]-8-|",
|
||||||
|
"H:|[text]|",
|
||||||
|
"H:|[buttons]|",
|
||||||
|
"V:|[label(<=80)][text(>=100)]-[buttons]-|",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ButtonsView = struct {
|
||||||
|
root: *c.GtkWidget,
|
||||||
|
|
||||||
|
pub fn init(root: *UnsafePaste) !ButtonsView {
|
||||||
|
const cancel_button = c.gtk_button_new_with_label("Cancel");
|
||||||
|
errdefer c.g_object_unref(cancel_button);
|
||||||
|
|
||||||
|
const paste_button = c.gtk_button_new_with_label("Paste");
|
||||||
|
errdefer c.g_object_unref(paste_button);
|
||||||
|
|
||||||
|
// TODO: Focus on the paste button
|
||||||
|
// c.gtk_widget_grab_focus(paste_button);
|
||||||
|
|
||||||
|
// Create our view
|
||||||
|
const view = try View.init(&.{
|
||||||
|
.{ .name = "cancel", .widget = cancel_button },
|
||||||
|
.{ .name = "paste", .widget = paste_button },
|
||||||
|
}, &vfl);
|
||||||
|
|
||||||
|
// Signals
|
||||||
|
_ = c.g_signal_connect_data(
|
||||||
|
cancel_button,
|
||||||
|
"clicked",
|
||||||
|
c.G_CALLBACK(>kCancelClick),
|
||||||
|
root,
|
||||||
|
null,
|
||||||
|
c.G_CONNECT_DEFAULT,
|
||||||
|
);
|
||||||
|
_ = c.g_signal_connect_data(
|
||||||
|
paste_button,
|
||||||
|
"clicked",
|
||||||
|
c.G_CALLBACK(>kPasteClick),
|
||||||
|
root,
|
||||||
|
null,
|
||||||
|
c.G_CONNECT_DEFAULT,
|
||||||
|
);
|
||||||
|
|
||||||
|
return .{ .root = view.root };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gtkCancelClick(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void {
|
||||||
|
const self: *UnsafePaste = @ptrCast(@alignCast(ud));
|
||||||
|
c.gtk_window_destroy(@ptrCast(self.window));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gtkPasteClick(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void {
|
||||||
|
// Requeue the paste with force.
|
||||||
|
const self: *UnsafePaste = @ptrCast(@alignCast(ud));
|
||||||
|
self.core_surface.completeClipboardRequest(
|
||||||
|
self.pending_req,
|
||||||
|
self.data,
|
||||||
|
true,
|
||||||
|
) catch |err| {
|
||||||
|
std.log.err("Failed to requeue clipboard request: {}", .{err});
|
||||||
|
};
|
||||||
|
|
||||||
|
c.gtk_window_destroy(@ptrCast(self.window));
|
||||||
|
}
|
||||||
|
|
||||||
|
const vfl = [_][*:0]const u8{
|
||||||
|
"H:[cancel]-8-[paste]-8-|",
|
||||||
|
};
|
||||||
|
};
|
@ -423,6 +423,13 @@ keybind: Keybinds = .{},
|
|||||||
/// This does not affect data sent to the clipboard via "clipboard-write".
|
/// This does not affect data sent to the clipboard via "clipboard-write".
|
||||||
@"clipboard-trim-trailing-spaces": bool = true,
|
@"clipboard-trim-trailing-spaces": bool = true,
|
||||||
|
|
||||||
|
/// Require confirmation before pasting text that appears unsafe. This helps
|
||||||
|
/// prevent a "copy/paste attack" where a user may accidentally execute unsafe
|
||||||
|
/// commands by pasting text with newlines.
|
||||||
|
///
|
||||||
|
/// This currently only works on Linux (GTK).
|
||||||
|
@"clipboard-paste-protection": bool = true,
|
||||||
|
|
||||||
/// The total amount of bytes that can be used for image data (i.e.
|
/// The total amount of bytes that can be used for image data (i.e.
|
||||||
/// the Kitty image protocol) per terminal scren. The maximum value
|
/// the Kitty image protocol) per terminal scren. The maximum value
|
||||||
/// is 4,294,967,295 (4GB). The default is 320MB. If this is set to zero,
|
/// is 4,294,967,295 (4GB). The default is 320MB. If this is set to zero,
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
const builtin = @import("builtin");
|
const builtin = @import("builtin");
|
||||||
|
|
||||||
|
pub usingnamespace @import("sanitize.zig");
|
||||||
|
|
||||||
const charsets = @import("charsets.zig");
|
const charsets = @import("charsets.zig");
|
||||||
const stream = @import("stream.zig");
|
const stream = @import("stream.zig");
|
||||||
const ansi = @import("ansi.zig");
|
const ansi = @import("ansi.zig");
|
||||||
|
13
src/terminal/sanitize.zig
Normal file
13
src/terminal/sanitize.zig
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
/// Returns true if the data looks safe to paste.
|
||||||
|
pub fn isSafePaste(data: []const u8) bool {
|
||||||
|
return std.mem.indexOf(u8, data, "\n") == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
test isSafePaste {
|
||||||
|
const testing = std.testing;
|
||||||
|
try testing.expect(isSafePaste("hello"));
|
||||||
|
try testing.expect(!isSafePaste("hello\n"));
|
||||||
|
try testing.expect(!isSafePaste("hello\nworld"));
|
||||||
|
}
|
Reference in New Issue
Block a user