From 81791a090f39c66f481dda6143309047077865e2 Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Wed, 12 Aug 2026 12:34:31 +0500 Subject: [PATCH] Quote choose-files shell output safely --- kittens/choose_files/main.go | 11 ++++------ kittens/choose_files/main.py | 27 +++++++++++------------ kittens/choose_files/main_test.go | 36 +++++++++++++++++++++++++++++++ kitty_tests/choose_files.py | 16 ++++++++++++++ 4 files changed, 69 insertions(+), 21 deletions(-) create mode 100644 kittens/choose_files/main_test.go create mode 100644 kitty_tests/choose_files.py diff --git a/kittens/choose_files/main.go b/kittens/choose_files/main.go index a70b823e7..751abe997 100644 --- a/kittens/choose_files/main.go +++ b/kittens/choose_files/main.go @@ -854,18 +854,15 @@ func (h *Handler) set_state_from_config(conf *Config, opts *Options) (err error) var default_cwd string var use_light_colors bool -func quote_if_needed(x string) string { - if s, err := shlex.Split(x); len(s) == 1 && err == nil && !strings.Contains(x, "$") { - return x - } - return utils.QuoteStringForSH(x) +func shell_output(selections []string) string { + return strings.Join(utils.Map(shlex.Quote, selections), " ") } func for_shell_relative(x string) string { if rel, is_under, err := utils.RelativeIfUnder(default_cwd, x, false); err == nil && is_under { x = rel } - return quote_if_needed(x) + return shlex.Quote(x) } func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) { @@ -912,7 +909,7 @@ func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) { var m string switch opts.OutputFormat { case "shell": - m = strings.Join(utils.Map(quote_if_needed, selections), " ") + m = shell_output(selections) case "shell-relative": m = strings.Join(utils.Map(for_shell_relative, selections), " ") case "text": diff --git a/kittens/choose_files/main.py b/kittens/choose_files/main.py index 2a979e25f..3b176c3a8 100644 --- a/kittens/choose_files/main.py +++ b/kittens/choose_files/main.py @@ -1,6 +1,7 @@ #!/usr/bin/env python # License: GPLv3 Copyright: 2025, Kovid Goyal +import shlex import sys from typing import Any @@ -249,12 +250,19 @@ def relative_path_if_possible(path: str, base: str) -> str: return path +def format_selection_for_paste(paths: list[str], cwd: str, at_prompt: bool) -> str: + items = [] + for path in paths: + if cwd: + path = relative_path_if_possible(path, cwd) + if at_prompt: + path = shlex.quote(path) + items.append(path) + return (' ' if at_prompt else '\n').join(items) + + @result_handler(has_ready_notification=True) def handle_result(args: list[str], data: dict[str, Any], target_window_id: int, boss: BossType) -> None: - import shlex - - from kitty.utils import shlex_split - paths: list[str] = data.get('paths', []) if not paths: boss.ring_bell_if_allowed() @@ -263,16 +271,7 @@ def handle_result(args: list[str], data: dict[str, Any], target_window_id: int, if w is None: boss.ring_bell_if_allowed() return - cwd = w.cwd_of_child - items = [] - for path in paths: - if cwd: - path = relative_path_if_possible(path, cwd) - if w.at_prompt and len(tuple(shlex_split(path))) > 1: - path = shlex.quote(path) - items.append(path) - text = (' ' if w.at_prompt else '\n').join(items) - w.paste_text(text) + w.paste_text(format_selection_for_paste(paths, w.cwd_of_child, w.at_prompt)) usage = '[directory to start choosing files in]' diff --git a/kittens/choose_files/main_test.go b/kittens/choose_files/main_test.go new file mode 100644 index 000000000..9d43411d9 --- /dev/null +++ b/kittens/choose_files/main_test.go @@ -0,0 +1,36 @@ +// License: GPLv3 Copyright: 2026, Kovid Goyal, + +package choose_files + +import ( + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/kovidgoyal/kitty/tools/utils/shlex" +) + +func TestShellOutput(t *testing.T) { + paths := []string{"simple", "a path", "a;command", "*.txt", "~root", "a>output", `a\b`, "it's"} + actual, err := shlex.Split(shell_output(paths)) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(paths, actual); diff != "" { + t.Fatalf("Shell output did not round-trip paths:\n%s", diff) + } + if q := shell_output([]string{"simple", "a/b"}); q != "simple a/b" { + t.Fatalf("Shell-safe paths were unnecessarily quoted: %q", q) + } + + original_cwd := default_cwd + default_cwd = t.TempDir() + t.Cleanup(func() { default_cwd = original_cwd }) + relative, err := shlex.Split(for_shell_relative(filepath.Join(default_cwd, "a;command"))) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff([]string{"a;command"}, relative); diff != "" { + t.Fatalf("Shell-relative output did not round-trip the path:\n%s", diff) + } +} diff --git a/kitty_tests/choose_files.py b/kitty_tests/choose_files.py new file mode 100644 index 000000000..08eabef31 --- /dev/null +++ b/kitty_tests/choose_files.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +# License: GPL v3 Copyright: 2026, Kovid Goyal + +import shlex + +from .base import BaseTest + + +class TestChooseFiles(BaseTest): + def test_format_selection_for_paste(self) -> None: + from kittens.choose_files.main import format_selection_for_paste + + paths = ['/work/simple', '/work/a path', '/work/a;command', '/work/*.txt', '/work/~root', '/work/a>output', r'/work/a\b', "/work/it's"] + text = format_selection_for_paste(paths, '/work', at_prompt=True) + self.assertEqual(shlex.split(text), [path.removeprefix('/work/') for path in paths]) + self.assertEqual(format_selection_for_paste(paths[:2], '/work', at_prompt=False), 'simple\na path')