diff --git a/test/test_cli.py b/test/test_cli.py new file mode 100644 index 0000000..0bca9e7 --- /dev/null +++ b/test/test_cli.py @@ -0,0 +1,17 @@ +from pathlib import Path +from unittest.mock import Mock, patch + +from topen import add_annotation, open_editor + + +def test_open_editor_escapes_shell(): + """Ensure filenames with spaces/metas do not allow shell injection.""" + with patch("subprocess.run") as run_mock: + open_editor(Path("my note$1.txt"), "vim") + run_mock.assert_called_once_with(["vim", "my note$1.txt"], check=True) + + +def test_add_annotation_calls_tasklib(): + task = Mock() + add_annotation(task, "hello") + task.add_annotation.assert_called_once_with("hello") diff --git a/topen.py b/topen.py index e04e39e..b5374e1 100755 --- a/topen.py +++ b/topen.py @@ -63,11 +63,9 @@ def main(cfg: "TConf | None" = None, io: "_IO | None" = None) -> int: fpath = get_notes_file(uuid, notes_dir=cfg.notes_dir, notes_ext=cfg.notes_ext) - if not fpath.parent.exists(): - fpath.parent.mkdir(parents=True, exist_ok=True) - + _ensure_parent_dir(fpath) io.out(f"Editing note: {fpath}") - open_editor(fpath, editor=cfg.notes_editor) + open_editor(fpath, editor=cfg.notes_editor, io=io) if fpath.exists(): if is_annotation_missing(task, annotation_content=cfg.notes_annot): @@ -97,9 +95,13 @@ def get_notes_file(uuid: str, notes_dir: Path, notes_ext: str) -> Path: return Path(notes_dir).joinpath(f"{uuid}.{notes_ext}") -def open_editor(file: Path, editor: str) -> None: +def open_editor(file: Path, editor: str, io: "_IO | None" = None) -> None: """Opens a file with the chosen editor.""" - _ = subprocess.run(f"{editor} {file}", shell=True) + try: + _ = subprocess.run([editor, str(file)], check=True) + except subprocess.CalledProcessError: + if io: + io.err("Editor exited with an error, aborting.\n") def is_annotation_missing(task: Task, annotation_content: str) -> bool: @@ -135,16 +137,21 @@ class Opt: is_flag: bool = False -def _real_path(p: Path | str) -> Path: +def _expand_path(p: Path | str) -> Path: return Path(os.path.expandvars(p)).expanduser() +def _ensure_parent_dir(file: Path) -> None: + if not file.parent.exists(): + file.parent.mkdir(parents=True, exist_ok=True) + + def _determine_default_task_rc() -> Path: - if _real_path("~/.taskrc").exists(): - return _real_path("~/.taskrc") - if _real_path("$XDG_CONFIG_HOME/task/taskrc").exists(): - return _real_path("$XDG_CONFIG_HOME/task/taskrc") - return _real_path("~/.config/task/taskrc") + if _expand_path("~/.taskrc").exists(): + return _expand_path("~/.taskrc") + if _expand_path("$XDG_CONFIG_HOME/task/taskrc").exists(): + return _expand_path("$XDG_CONFIG_HOME/task/taskrc") + return _expand_path("~/.config/task/taskrc") def _strtobool(val: str) -> bool: @@ -258,11 +265,11 @@ class TConf: """If set topen will give no feedback on note editing.""" def __post_init__(self): - self.task_rc = _real_path(self.task_rc) - self.task_data = _real_path(self.task_data) + self.task_rc = _expand_path(self.task_rc) + self.task_data = _expand_path(self.task_data) if self.notes_dir == NON_EXISTENT_PATH: self.notes_dir = self._default_notes_dir() - self.notes_dir = _real_path(self.notes_dir) + self.notes_dir = _expand_path(self.notes_dir) if not self.notes_editor: self.notes_editor = ( os.getenv("EDITOR")