From 8708514a12d7f42800407f3c050fe5837c6e4518 Mon Sep 17 00:00:00 2001 From: Kieron Briggs Date: Wed, 9 Feb 2022 15:46:54 +1100 Subject: [PATCH 01/13] Test cases for specific shell behaviour Adds support to fakessh to emulate a specific shell with respect to prompt setting. The shell is specified as a parameter after the hostname, similar to real ssh taking a command to execute on the remote. This is slightly awkward when calling pxssh.login() as it means giving the server parameter as 'hostname shell', but this is only needed for the unit tests. --- tests/fakessh/ssh | 27 ++++++++++++++++++--------- tests/test_pxssh.py | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/tests/fakessh/ssh b/tests/fakessh/ssh index 4a5be1bd..791656ae 100755 --- a/tests/fakessh/ssh +++ b/tests/fakessh/ssh @@ -9,19 +9,14 @@ if not PY3: input = raw_input ssh_usage = "usage: ssh [-2qV] [-c cipher_spec] [-l login_name]\r\n" \ - + " hostname" + + " hostname [shell]" cipher_valid_list = ['aes128-ctr', 'aes192-ctr', 'aes256-ctr', 'arcfour256', 'arcfour128', \ 'aes128-cbc','3des-cbc','blowfish-cbc','cast128-cbc','aes192-cbc', \ 'aes256-cbc','arcfour'] try: - server = sys.argv[-1] - if server == 'noserver': - print('No route to host') - sys.exit(1) - - elif len(sys.argv) < 2: + if len(sys.argv) < 2: print(ssh_usage) sys.exit(1) @@ -29,7 +24,7 @@ try: cipher_list = [] fullCmdArguments = sys.argv argumentList = fullCmdArguments[1:] - unixOptions = "2qVc:l" + unixOptions = "2qVc:l:" arguments, values = getopt.getopt(argumentList, unixOptions) for currentArgument, currentValue in arguments: if currentArgument in ("-2"): @@ -45,6 +40,14 @@ try: print("Unknown cipher type '" + str(cipher_item) + "'") sys.exit(1) + server = values[0] + if server == 'noserver': + print('No route to host') + sys.exit(1) + + shell = 'bash' + if len(values) > 1: + shell = values[1] except Exception as e: print(ssh_usage) @@ -62,7 +65,13 @@ prompt = "$" while True: cmd = input(prompt) if cmd.startswith('PS1='): - prompt = eval(cmd[4:]).replace('\$', '$') + if shell == 'bash': + prompt = eval(cmd[4:]).replace('\$', '$') + elif shell == 'zsh': + prompt = eval(cmd[4:]).replace('%(!.#.$)', '$') + elif cmd.startswith('set prompt='): + if shell.endswith('csh'): + prompt = eval(cmd[11:]).replace('\$', '$') elif cmd == 'ping': print('pong') elif cmd.startswith('ls'): diff --git a/tests/test_pxssh.py b/tests/test_pxssh.py index c6ec4e2c..fed552a0 100644 --- a/tests/test_pxssh.py +++ b/tests/test_pxssh.py @@ -274,5 +274,29 @@ def test_failed_custom_ssh_cmd(self): else: assert False, 'should have raised exception, pxssh.ExceptionPxssh' + def test_login_bash(self): + ssh = pxssh.pxssh() + result = ssh.login('server bash', 'me', password='s3cret') + ssh.sendline('ping') + ssh.expect('pong', timeout=10) + assert ssh.prompt(timeout=10) + ssh.logout() + + def test_login_zsh(self): + ssh = pxssh.pxssh() + result = ssh.login('server zsh', 'me', password='s3cret') + ssh.sendline('ping') + ssh.expect('pong', timeout=10) + assert ssh.prompt(timeout=10) + ssh.logout() + + def test_login_tcsh(self): + ssh = pxssh.pxssh() + result = ssh.login('server tcsh', 'me', password='s3cret') + ssh.sendline('ping') + ssh.expect('pong', timeout=10) + assert ssh.prompt(timeout=10) + ssh.logout() + if __name__ == '__main__': unittest.main() From cdf1846029e02853294be8668a574d2acc2cd851 Mon Sep 17 00:00:00 2001 From: Kieron Briggs Date: Wed, 9 Feb 2022 15:54:06 +1100 Subject: [PATCH 02/13] Set prompt correctly for zsh When the remote system/user is using zsh as their login shell, `\$` is not an escape sequence for the prompt, and so self.PROMPT fails to match because the backslash is included in the received text. The equivalent escape sequence for zsh is `%(!.#.$)`, so add a self.PROMPT_SET_ZSH using this sequence. Fixes #711 --- pexpect/pxssh.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pexpect/pxssh.py b/pexpect/pxssh.py index 3d53bd97..22afa432 100644 --- a/pexpect/pxssh.py +++ b/pexpect/pxssh.py @@ -143,6 +143,7 @@ def __init__ (self, timeout=30, maxread=2000, searchwindowsize=None, # used to set shell command-line prompt to UNIQUE_PROMPT. self.PROMPT_SET_SH = r"PS1='[PEXPECT]\$ '" self.PROMPT_SET_CSH = r"set prompt='[PEXPECT]\$ '" + self.PROMPT_SET_ZSH = r"PS1='[PEXPECT]%(!.#.$) '" self.SSH_OPTS = ("-o'RSAAuthentication=no'" + " -o 'PubkeyAuthentication=no'") # Disabling host key checking, makes you vulnerable to MITM attacks. @@ -527,11 +528,14 @@ def set_unique_prompt(self): self.sendline("unset PROMPT_COMMAND") self.sendline(self.PROMPT_SET_SH) # sh-style i = self.expect ([TIMEOUT, self.PROMPT], timeout=10) - if i == 0: # csh-style - self.sendline(self.PROMPT_SET_CSH) + if i == 0: # zsh-style + self.sendline(self.PROMPT_SET_ZSH) i = self.expect([TIMEOUT, self.PROMPT], timeout=10) - if i == 0: - return False + if i == 0: # csh-style + self.sendline(self.PROMPT_SET_CSH) + i = self.expect([TIMEOUT, self.PROMPT], timeout=10) + if i == 0: + return False return True # vi:ts=4:sw=4:expandtab:ft=python: From 686fa6fb7a32a2f36e1d0370a0d2c9d36fa5cb3e Mon Sep 17 00:00:00 2001 From: Kieron Briggs Date: Wed, 9 Feb 2022 16:54:12 +1100 Subject: [PATCH 03/13] Add `prompt restore` to PROMPT_SET_ZSH Many zsh users also use the prompt theme system from oh-my-zsh. This overrides the `PS1` setting, so setting the prompt fails for these users. Add the `prompt restore` command to disable oh-my-zsh's prompt and use the set `PS1` value. --- pexpect/pxssh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pexpect/pxssh.py b/pexpect/pxssh.py index 22afa432..0210e8af 100644 --- a/pexpect/pxssh.py +++ b/pexpect/pxssh.py @@ -143,7 +143,7 @@ def __init__ (self, timeout=30, maxread=2000, searchwindowsize=None, # used to set shell command-line prompt to UNIQUE_PROMPT. self.PROMPT_SET_SH = r"PS1='[PEXPECT]\$ '" self.PROMPT_SET_CSH = r"set prompt='[PEXPECT]\$ '" - self.PROMPT_SET_ZSH = r"PS1='[PEXPECT]%(!.#.$) '" + self.PROMPT_SET_ZSH = "prompt restore;\nPS1='[PEXPECT]%(!.#.$) '" self.SSH_OPTS = ("-o'RSAAuthentication=no'" + " -o 'PubkeyAuthentication=no'") # Disabling host key checking, makes you vulnerable to MITM attacks. From e7cf54a7aae38a3986284f64ae94e00d2f86ae40 Mon Sep 17 00:00:00 2001 From: Kieron Briggs Date: Thu, 10 Nov 2022 15:06:08 +1100 Subject: [PATCH 04/13] Switch order of trying csh and zsh prompts Restore the sh-then-csh order of attempting to set a unique prompt, add zsh to the end of the list instead of in the middle. (Fixes https://github.com/pexpect/pexpect/pull/712#discussion_r1018063824) --- pexpect/pxssh.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pexpect/pxssh.py b/pexpect/pxssh.py index 0210e8af..6f73c881 100644 --- a/pexpect/pxssh.py +++ b/pexpect/pxssh.py @@ -528,11 +528,11 @@ def set_unique_prompt(self): self.sendline("unset PROMPT_COMMAND") self.sendline(self.PROMPT_SET_SH) # sh-style i = self.expect ([TIMEOUT, self.PROMPT], timeout=10) - if i == 0: # zsh-style - self.sendline(self.PROMPT_SET_ZSH) + if i == 0: # csh-style + self.sendline(self.PROMPT_SET_CSH) i = self.expect([TIMEOUT, self.PROMPT], timeout=10) - if i == 0: # csh-style - self.sendline(self.PROMPT_SET_CSH) + if i == 0: # zsh-style + self.sendline(self.PROMPT_SET_ZSH) i = self.expect([TIMEOUT, self.PROMPT], timeout=10) if i == 0: return False From 8d5bfe7bc06309cd3145e3f0ec078b9172daa14b Mon Sep 17 00:00:00 2001 From: Kieron Briggs Date: Tue, 7 Mar 2023 16:40:57 +1100 Subject: [PATCH 05/13] Restore execute permission for fakessh Merge commit eae7087 somehow removed the execute bit from the mode of tests/fakessh/ssh, breaking all tests using that mock ssh client. --- tests/fakessh/ssh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 tests/fakessh/ssh diff --git a/tests/fakessh/ssh b/tests/fakessh/ssh old mode 100644 new mode 100755 From ed2dc26ae43d8deec8e44f06be32ffb1847eff6f Mon Sep 17 00:00:00 2001 From: Andrey Kislyuk Date: Sat, 1 Apr 2023 14:59:36 -0700 Subject: [PATCH 06/13] Add zsh convenience function to replwrap module --- pexpect/replwrap.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/pexpect/replwrap.py b/pexpect/replwrap.py index 6c34ce41..df38434b 100644 --- a/pexpect/replwrap.py +++ b/pexpect/replwrap.py @@ -112,19 +112,25 @@ def python(command=sys.executable): """Start a Python shell and return a :class:`REPLWrapper` object.""" return REPLWrapper(command, u">>> ", u"import sys; sys.ps1={0!r}; sys.ps2={1!r}") -def bash(command="bash"): - """Start a bash shell and return a :class:`REPLWrapper` object.""" - bashrc = os.path.join(os.path.dirname(__file__), 'bashrc.sh') - child = pexpect.spawn(command, ['--rcfile', bashrc], echo=False, - encoding='utf-8') +def _repl_sh(command, args, non_printable_insert): + child = pexpect.spawn(command, args, echo=False, encoding='utf-8') # If the user runs 'env', the value of PS1 will be in the output. To avoid # replwrap seeing that as the next prompt, we'll embed the marker characters # for invisible characters in the prompt; these show up when inspecting the # environment variable, but not when bash displays the prompt. - ps1 = PEXPECT_PROMPT[:5] + u'\\[\\]' + PEXPECT_PROMPT[5:] - ps2 = PEXPECT_CONTINUATION_PROMPT[:5] + u'\\[\\]' + PEXPECT_CONTINUATION_PROMPT[5:] + ps1 = PEXPECT_PROMPT[:5] + non_printable_insert + PEXPECT_PROMPT[5:] + ps2 = PEXPECT_CONTINUATION_PROMPT[:5] + non_printable_insert + PEXPECT_CONTINUATION_PROMPT[5:] prompt_change = u"PS1='{0}' PS2='{1}' PROMPT_COMMAND=''".format(ps1, ps2) return REPLWrapper(child, u'\\$', prompt_change, extra_init_cmd="export PAGER=cat") + +def bash(command="bash"): + """Start a bash shell and return a :class:`REPLWrapper` object.""" + bashrc = os.path.join(os.path.dirname(__file__), 'bashrc.sh') + return _repl_sh(command, ['--rcfile', bashrc], non_printable_insert='\\[\\]') + +def zsh(command="zsh"): + """Start a zsh shell and return a :class:`REPLWrapper` object.""" + return _repl_sh(command, ['--no-rcs'], non_printable_insert='%G') From 49de749ea0d271196448c0ac9ab22f4fc31ee64c Mon Sep 17 00:00:00 2001 From: Andrey Kislyuk Date: Sat, 1 Apr 2023 15:18:56 -0700 Subject: [PATCH 07/13] Add test case --- tests/test_replwrap.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_replwrap.py b/tests/test_replwrap.py index e29080da..891c1737 100644 --- a/tests/test_replwrap.py +++ b/tests/test_replwrap.py @@ -95,6 +95,18 @@ def test_existing_spawn(self): res = repl.run_command("echo $HOME") assert res.startswith('/'), res + def test_zsh(self): + zsh = replwrap.zsh() + res = zsh.run_command("env") + assert 'PAGER' in res, res + + try: + zsh.run_command('') + except ValueError: + pass + else: + assert False, "Didn't raise ValueError for empty input" + def test_python(self): if platform.python_implementation() == 'PyPy': raise unittest.SkipTest(skip_pypy) From dc6442801913a36cf34f29d86065d944869ffa1c Mon Sep 17 00:00:00 2001 From: Andrey Kislyuk Date: Sun, 2 Apr 2023 08:56:53 -0700 Subject: [PATCH 08/13] Disable ZLE and PROMPT_SP in zsh --- pexpect/replwrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pexpect/replwrap.py b/pexpect/replwrap.py index df38434b..033954ed 100644 --- a/pexpect/replwrap.py +++ b/pexpect/replwrap.py @@ -133,4 +133,4 @@ def bash(command="bash"): def zsh(command="zsh"): """Start a zsh shell and return a :class:`REPLWrapper` object.""" - return _repl_sh(command, ['--no-rcs'], non_printable_insert='%G') + return _repl_sh(command, ['--no-rcs', '-V', '+Z'], non_printable_insert='%G') From efa4e5d00ef7bd71b57fca1bdcd172d289659027 Mon Sep 17 00:00:00 2001 From: Andrey Kislyuk Date: Sun, 2 Apr 2023 12:47:31 -0700 Subject: [PATCH 09/13] Make args customizable --- pexpect/replwrap.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pexpect/replwrap.py b/pexpect/replwrap.py index 033954ed..7dfd80ff 100644 --- a/pexpect/replwrap.py +++ b/pexpect/replwrap.py @@ -131,6 +131,6 @@ def bash(command="bash"): bashrc = os.path.join(os.path.dirname(__file__), 'bashrc.sh') return _repl_sh(command, ['--rcfile', bashrc], non_printable_insert='\\[\\]') -def zsh(command="zsh"): +def zsh(command="zsh", args=("--no-rcs", "-V", "+Z")): """Start a zsh shell and return a :class:`REPLWrapper` object.""" - return _repl_sh(command, ['--no-rcs', '-V', '+Z'], non_printable_insert='%G') + return _repl_sh(command, list(args), non_printable_insert='%G') From 5d8a377163b54a599bff13923d3b31d14eda9eea Mon Sep 17 00:00:00 2001 From: Andrey Kislyuk Date: Sun, 2 Apr 2023 12:49:55 -0700 Subject: [PATCH 10/13] Install zsh in GHA --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbfbbea7..b268257f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ jobs: - name: Install packages run: | + sudo apt-get install --yes zsh export PYTHONIOENCODING=UTF8 pip install coveralls pytest-cov ptyprocess From f993450c1c9d91e535d2fffeb0ee87951c26bb3f Mon Sep 17 00:00:00 2001 From: Andrey Kislyuk Date: Sun, 2 Apr 2023 14:32:58 -0700 Subject: [PATCH 11/13] Use ternary inline conditional instead of glitch directive --- pexpect/replwrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pexpect/replwrap.py b/pexpect/replwrap.py index 7dfd80ff..08dbd5e8 100644 --- a/pexpect/replwrap.py +++ b/pexpect/replwrap.py @@ -133,4 +133,4 @@ def bash(command="bash"): def zsh(command="zsh", args=("--no-rcs", "-V", "+Z")): """Start a zsh shell and return a :class:`REPLWrapper` object.""" - return _repl_sh(command, list(args), non_printable_insert='%G') + return _repl_sh(command, list(args), non_printable_insert='%(!..)') From ddd44c9c5c66baea6c08f08119af3f6739646a3d Mon Sep 17 00:00:00 2001 From: Tim Landscheidt Date: Sat, 8 Apr 2023 15:51:25 +0000 Subject: [PATCH 12/13] Clean up temporary files after UnicodeTests --- tests/test_unicode.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_unicode.py b/tests/test_unicode.py index 61031672..e38563d3 100644 --- a/tests/test_unicode.py +++ b/tests/test_unicode.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +import os import platform import tempfile import sys @@ -124,11 +125,13 @@ def open(fname, mode, **kwargs): # ensure the 'send' log is correct, with open(filename_send, 'r', encoding='utf-8') as f: self.assertEqual(f.read(), msg + '\n\x04') + os.unlink(filename_send) # ensure the 'read' log is correct, with open(filename_read, 'r', encoding='utf-8', newline='') as f: output = f.read().replace(_CAT_EOF, '') self.assertEqual(output, (msg + '\r\n')*2 ) + os.unlink(filename_read) def test_spawn_expect_ascii_unicode(self): From 3793706f8d391dec9cc4acb1e8cb18c07fb7f2c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Collonval?= Date: Tue, 8 Aug 2023 14:46:53 +0200 Subject: [PATCH 13/13] Add Python 3.5, 3.6 and 3.12.0-rc.1 to test matrix --- .github/workflows/ci.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbfbbea7..29b3e277 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,11 +8,19 @@ on: jobs: test: - runs-on: ubuntu-22.04 + name: Test Python ${{ matrix.python-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "pypy3.9"] + os: ["ubuntu-22.04"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12.0-rc.1", "pypy3.9"] + include: + # Python < 3.7 is not available on ubuntu-22.04 + - os: "ubuntu-20.04" + python-version: "3.5" + - os: "ubuntu-20.04" + python-version: "3.6" steps: - uses: actions/checkout@v3