From 84375cd48268ada8a8f7d12009f3af591907b918 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Fri, 10 Apr 2026 14:58:29 +0200 Subject: [PATCH] lib.ec.ssh: Don't quote shell operators Naively join()ing a command list to be executed remotely via SSH also quotes shell operators which doesn't work, of course. Work around that. The workaround will not always work but covers lots of cases. Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/ec/ssh/AsyncSSH.py | 4 ++- src/python/jw/pkg/lib/ec/ssh/Exec.py | 5 ++- src/python/jw/pkg/lib/ec/ssh/Paramiko.py | 7 ++-- src/python/jw/pkg/lib/ec/ssh/util.py | 41 ++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 src/python/jw/pkg/lib/ec/ssh/util.py diff --git a/src/python/jw/pkg/lib/ec/ssh/AsyncSSH.py b/src/python/jw/pkg/lib/ec/ssh/AsyncSSH.py index 67b110a3..dd8ec2ae 100644 --- a/src/python/jw/pkg/lib/ec/ssh/AsyncSSH.py +++ b/src/python/jw/pkg/lib/ec/ssh/AsyncSSH.py @@ -6,6 +6,8 @@ from ...log import * from ...ExecContext import Result from ..SSHClient import SSHClient as Base +from .util import join_cmd + _USE_DEFAULT_KNOWN_HOSTS = object() class AsyncSSH(Base): @@ -52,7 +54,7 @@ class AsyncSSH(Base): if not cmd: raise ValueError("cmd must not be empty") - inner = f"exec {shlex.join(cmd)}" + inner = f"exec {join_cmd(cmd)}" if wd is not None: inner = f"cd {shlex.quote(wd)} && {inner}" diff --git a/src/python/jw/pkg/lib/ec/ssh/Exec.py b/src/python/jw/pkg/lib/ec/ssh/Exec.py index 8202c5c4..b1267800 100644 --- a/src/python/jw/pkg/lib/ec/ssh/Exec.py +++ b/src/python/jw/pkg/lib/ec/ssh/Exec.py @@ -2,10 +2,9 @@ from __future__ import annotations from typing import TYPE_CHECKING -import shlex - from ...util import run_cmd from ..SSHClient import SSHClient as Base +from .util import join_cmd if TYPE_CHECKING: from ...ExecContext import Result @@ -41,5 +40,5 @@ class Exec(Base): async def _run_ssh(self, cmd: list[str], cmd_input: str|None, *args, **kwargs) -> Result: self.__init_askpass() - return await run_cmd(['ssh', self.hostname, shlex.join(cmd)], cmd_input=cmd_input) + return await run_cmd(['ssh', self.hostname, join_cmd(cmd)], cmd_input=cmd_input) diff --git a/src/python/jw/pkg/lib/ec/ssh/Paramiko.py b/src/python/jw/pkg/lib/ec/ssh/Paramiko.py index 7ed4ae33..57c3bc44 100644 --- a/src/python/jw/pkg/lib/ec/ssh/Paramiko.py +++ b/src/python/jw/pkg/lib/ec/ssh/Paramiko.py @@ -3,12 +3,13 @@ from __future__ import annotations from typing import TYPE_CHECKING import paramiko # type: ignore # error: Library stubs not installed for "paramiko" -import shlex from ...log import * from ...ExecContext import Result from ..SSHClient import SSHClient as Base +from .util import join_cmd + class Paramiko(Base): def __init__(self, *args, **kwargs) -> None: @@ -45,9 +46,9 @@ class Paramiko(Base): async def _run_ssh(self, cmd: list[str], cmd_input: str|None, *args, **kwargs) -> Result: try: - stdin, stdout, stderr = self.__ssh.exec_command(shlex.join(cmd), timeout=self.__timeout) + stdin, stdout, stderr = self.__ssh.exec_command(join_cmd(cmd), timeout=self.__timeout) except Exception as e: - log(ERR, f'Command failed for {self.uri}: "{shlex.join(cmd)}"') + log(ERR, f'Command failed for {self.uri}: "{join_cmd(cmd)}"') raise if cmd_input is not None: stdin.write(cmd_input) diff --git a/src/python/jw/pkg/lib/ec/ssh/util.py b/src/python/jw/pkg/lib/ec/ssh/util.py new file mode 100644 index 00000000..b7cd6a9b --- /dev/null +++ b/src/python/jw/pkg/lib/ec/ssh/util.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- + +from typing import Iterable + +import shlex + +DEFAULT_SHELL_OPERATORS = { + # redirections + ">", ">>", "<", "<<", "<<-", "<&", ">&", "<>", ">|", + "1>", "1>>", "2>", "2>>", + + # pipelines / control + "|", "||", "&", "&&", ";", + + # grouping + "(", ")", +} + +def join_cmd( + cmd: Iterable[str], + operators: set[str] = DEFAULT_SHELL_OPERATORS, +) -> str: + """ + Join a token list into a POSIX-shell command string. + + Tokens in `operators` are emitted verbatim. + Everything else is shell-quoted with shlex.quote(). + + Example: + ["echo", "hello world", ">", "/tmp/out file"] + -> "echo 'hello world' > '/tmp/out file'" + """ + ret: list[str] = [] + for token in cmd: + if not isinstance(token, str): + token = str(token) + if token in operators: + ret.append(token) + else: + ret.append(shlex.quote(token)) + return ' '.join(ret)