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 <jan@janware.com>
This commit is contained in:
Jan Lindemann 2026-04-10 14:58:29 +02:00
commit 84375cd482
4 changed files with 50 additions and 7 deletions

View file

@ -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}"

View file

@ -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)

View file

@ -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)

View file

@ -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)