py-posix-shell 0.4.2__tar.gz → 0.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-posix-shell
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: A cross-platform POSIX-style shell written in Python.
5
5
  Author: GGN_2015
6
6
  License-Expression: MIT
@@ -56,9 +56,9 @@ scripts:
56
56
  otherwise falls back to Python implementations of common tools including
57
57
  `ls`, `cat`, `cp`, `mv`, `rm`, `install`, `unlink`, `mkdir`, `rmdir`, `pwd`,
58
58
  `sort`, `uniq`, `wc`, `cut`, `head`, `tail`, `chmod`, `chown`, `chgrp`,
59
- `echo`, `date`, `sleep`, `printf`, `basename`, `dirname`, `whoami`, `yes`,
59
+ `echo`, `date`, `sleep`, `printf`, `basename`, `dirname`, `whoami`, `yes`, `clear`,
60
60
  `find`, `xargs`, `locate`, `updatedb`, `diff`, `cmp`, `diff3`, `sdiff`,
61
- `grep`, `egrep`, `fgrep`, `sed`, `awk`, `gawk`, and `tar`
61
+ `grep`, `egrep`, `fgrep`, `sed`, `awk`, `gawk`, `base64`, `tr`, and `tar`
62
62
 
63
63
  The implementation intentionally remains dependency-free at runtime. It aims for
64
64
  useful POSIX behavior first, then progressively tighter compatibility with
@@ -33,9 +33,9 @@ scripts:
33
33
  otherwise falls back to Python implementations of common tools including
34
34
  `ls`, `cat`, `cp`, `mv`, `rm`, `install`, `unlink`, `mkdir`, `rmdir`, `pwd`,
35
35
  `sort`, `uniq`, `wc`, `cut`, `head`, `tail`, `chmod`, `chown`, `chgrp`,
36
- `echo`, `date`, `sleep`, `printf`, `basename`, `dirname`, `whoami`, `yes`,
36
+ `echo`, `date`, `sleep`, `printf`, `basename`, `dirname`, `whoami`, `yes`, `clear`,
37
37
  `find`, `xargs`, `locate`, `updatedb`, `diff`, `cmp`, `diff3`, `sdiff`,
38
- `grep`, `egrep`, `fgrep`, `sed`, `awk`, `gawk`, and `tar`
38
+ `grep`, `egrep`, `fgrep`, `sed`, `awk`, `gawk`, `base64`, `tr`, and `tar`
39
39
 
40
40
  The implementation intentionally remains dependency-free at runtime. It aims for
41
41
  useful POSIX behavior first, then progressively tighter compatibility with
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "py-posix-shell"
7
- version = "0.4.2"
7
+ version = "0.4.4"
8
8
  description = "A cross-platform POSIX-style shell written in Python."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """A cross-platform POSIX-style shell."""
2
2
 
3
- __version__ = "0.4.2"
3
+ __version__ = "0.4.4"
@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
6
6
  from typing import TypeAlias
7
7
 
8
8
  from .errors import ParseError
9
- from .lexer import Operator, Token, Word, is_name, lex
9
+ from .lexer import Operator, Part, Token, Word, is_name, lex
10
10
 
11
11
 
12
12
  @dataclass(frozen=True)
@@ -293,6 +293,9 @@ class Parser:
293
293
 
294
294
  while not self._at_end() and not self._at_stop(stop_words, stop_ops):
295
295
  token = self._peek()
296
+ if isinstance(token, Word) and self._is_array_append_assignment(token) and not seen_command_word:
297
+ assignments.append(self._parse_array_append_assignment())
298
+ continue
296
299
  if isinstance(token, Operator):
297
300
  redir = parse_redirection_operator(token.value)
298
301
  if redir is None:
@@ -315,6 +318,22 @@ class Parser:
315
318
  raise ParseError(f"expected command, got {self._describe(token)}")
316
319
  return SimpleCommand(tuple(assignments), tuple(words), tuple(redirections))
317
320
 
321
+ def _is_array_append_assignment(self, token: Word) -> bool:
322
+ return (
323
+ token.text.endswith("+=")
324
+ and is_name(token.text[:-2])
325
+ and self._peek_operator(1) == "("
326
+ )
327
+
328
+ def _parse_array_append_assignment(self) -> tuple[str, Word]:
329
+ name = self._consume_word().text[:-2]
330
+ self._expect_operator("(")
331
+ values: list[str] = []
332
+ while not self._at_end() and self._peek_operator() != ")":
333
+ values.append(self._consume_word().text)
334
+ self._expect_operator(")")
335
+ return name, Word((Part(" ".join(values)),))
336
+
318
337
  def _parse_redirections(self) -> tuple[Redirection, ...]:
319
338
  redirections: list[Redirection] = []
320
339
  while not self._at_end():
@@ -3,6 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import ast
6
+ import base64
7
+ import binascii
6
8
  import datetime as _datetime
7
9
  import difflib
8
10
  import fnmatch
@@ -51,6 +53,124 @@ def utility_cat(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: T
51
53
  return status
52
54
 
53
55
 
56
+ def utility_base64(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
57
+ decode = False
58
+ paths: list[str] = []
59
+ i = 1
60
+ while i < len(argv):
61
+ arg = argv[i]
62
+ if arg == "--":
63
+ paths.extend(argv[i + 1 :])
64
+ break
65
+ if arg in {"-d", "-D", "--decode"}:
66
+ decode = True
67
+ elif arg in {"-w", "--wrap"}:
68
+ i += 1
69
+ if i >= len(argv):
70
+ stderr.write("base64: option requires an argument -- w\n")
71
+ return 2
72
+ elif arg.startswith("-w") and len(arg) > 2:
73
+ pass
74
+ elif arg.startswith("-") and arg != "-":
75
+ for flag in arg[1:]:
76
+ if flag in {"d", "D"}:
77
+ decode = True
78
+ else:
79
+ stderr.write(f"base64: invalid option -- {flag}\n")
80
+ return 2
81
+ else:
82
+ paths.append(arg)
83
+ i += 1
84
+
85
+ try:
86
+ data = b"".join(read_binary_inputs(paths or ["-"], stdin))
87
+ if decode:
88
+ decoded = base64.b64decode(b"".join(data.split()), validate=False)
89
+ write_binary_output(stdout, decoded)
90
+ else:
91
+ encoded = base64.encodebytes(data)
92
+ write_binary_output(stdout, encoded)
93
+ except (binascii.Error, ValueError) as exc:
94
+ stderr.write(f"base64: invalid input: {exc}\n")
95
+ return 1
96
+ except OSError as exc:
97
+ stderr.write(f"base64: {exc.strerror or exc}\n")
98
+ return 1
99
+ return 0
100
+
101
+
102
+ def utility_tr(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
103
+ delete = False
104
+ squeeze = False
105
+ args: list[str] = []
106
+ for arg in argv[1:]:
107
+ if arg == "--":
108
+ continue
109
+ if arg.startswith("-") and arg != "-":
110
+ for flag in arg[1:]:
111
+ if flag == "d":
112
+ delete = True
113
+ elif flag == "s":
114
+ squeeze = True
115
+ else:
116
+ stderr.write(f"tr: invalid option -- {flag}\n")
117
+ return 2
118
+ else:
119
+ args.append(arg)
120
+ if delete:
121
+ if not args:
122
+ stderr.write("tr: missing operand\n")
123
+ return 2
124
+ delete_chars = set(expand_tr_set(args[0]))
125
+ squeeze_chars = set(expand_tr_set(args[1])) if squeeze and len(args) > 1 else delete_chars
126
+ result = "".join(char for char in stdin.read() if char not in delete_chars)
127
+ stdout.write(squeeze_repeated_chars(result, squeeze_chars) if squeeze else result)
128
+ return 0
129
+ if len(args) < 2:
130
+ stderr.write("tr: missing operand after set1\n")
131
+ return 2
132
+ source = expand_tr_set(args[0])
133
+ target = expand_tr_set(args[1])
134
+ if not target:
135
+ stderr.write("tr: set2 must not be empty\n")
136
+ return 2
137
+ translation: dict[int, str] = {}
138
+ for index, char in enumerate(source):
139
+ translation[ord(char)] = target[index] if index < len(target) else target[-1]
140
+ result = stdin.read().translate(translation)
141
+ if squeeze:
142
+ result = squeeze_repeated_chars(result, set(target))
143
+ stdout.write(result)
144
+ return 0
145
+
146
+
147
+ def utility_clear(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
148
+ index = 1
149
+ while index < len(argv):
150
+ arg = argv[index]
151
+ if arg == "--":
152
+ break
153
+ if arg == "-T":
154
+ index += 2
155
+ continue
156
+ if arg in {"-V", "--version"}:
157
+ stdout.write("py-posix-shell clear fallback\n")
158
+ return 0
159
+ if arg in {"-x"}:
160
+ index += 1
161
+ continue
162
+ if arg.startswith("-"):
163
+ stderr.write(f"clear: invalid option: {arg}\n")
164
+ return 2
165
+ index += 1
166
+ stdout.write("\033[H\033[2J")
167
+ try:
168
+ stdout.flush()
169
+ except OSError:
170
+ pass
171
+ return 0
172
+
173
+
54
174
  def utility_ls(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
55
175
  show_all = False
56
176
  almost_all = False
@@ -389,11 +509,15 @@ def utility_wc(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: Te
389
509
 
390
510
  rows: list[tuple[int, int, int, str | None]] = []
391
511
  status = 0
392
- for path, text in read_text_inputs(paths or ["-"], stdin, stderr, "wc"):
393
- if text is None:
512
+ for path in paths or ["-"]:
513
+ try:
514
+ data = next(iter(read_binary_inputs([path], stdin)))
515
+ except OSError as exc:
516
+ stderr.write(f"wc: {path}: {exc.strerror or exc}\n")
394
517
  status = 1
395
518
  continue
396
- rows.append((text.count("\n"), len(text.split()), len(text.encode("utf-8")), None if path == "-" else path))
519
+ text = data.decode("utf-8", errors="replace")
520
+ rows.append((data.count(b"\n"), len(text.split()), len(data), None if path == "-" else path))
397
521
  for lines, words, bytes_count, name in rows:
398
522
  values: list[str] = []
399
523
  if count_lines:
@@ -1518,12 +1642,13 @@ def display_name(entry: Path, base: Path) -> str:
1518
1642
 
1519
1643
  def format_long_listing(path: Path, name: str) -> str:
1520
1644
  try:
1521
- info = path.stat()
1645
+ info = path.lstat()
1522
1646
  except OSError:
1523
- return f"?????????? 0 {name}"
1647
+ return f"?????????? 0 unknown unknown 0 Jan 01 00:00 {name}"
1524
1648
  mode = stat.filemode(info.st_mode)
1525
1649
  mtime = _datetime.datetime.fromtimestamp(info.st_mtime).strftime("%b %d %H:%M")
1526
- return f"{mode} 1 {info.st_size:>8} {mtime} {name}"
1650
+ owner, group = owner_group_names(info)
1651
+ return f"{mode} {getattr(info, 'st_nlink', 1):>2} {owner:<8} {group:<8} {info.st_size:>8} {mtime} {name}"
1527
1652
 
1528
1653
 
1529
1654
  def parse_line_count_args(command: str, argv: list[str], stderr: TextIO, *, default: int) -> tuple[int, list[str], int]:
@@ -1616,6 +1741,127 @@ def parse_csv_line(line: str) -> list[str]:
1616
1741
  return result
1617
1742
 
1618
1743
 
1744
+ def read_binary_inputs(paths: Iterable[str], stdin: TextIO) -> Iterable[bytes]:
1745
+ for path in paths:
1746
+ if path == "-":
1747
+ buffer = getattr(stdin, "buffer", None)
1748
+ if buffer is not None:
1749
+ yield buffer.read()
1750
+ else:
1751
+ yield stdin.read().encode("utf-8")
1752
+ continue
1753
+ yield Path(path).read_bytes()
1754
+
1755
+
1756
+ def write_binary_output(stdout: TextIO, data: bytes) -> None:
1757
+ buffer = getattr(stdout, "buffer", None)
1758
+ if buffer is not None:
1759
+ buffer.write(data)
1760
+ buffer.flush()
1761
+ else:
1762
+ stdout.write(data.decode("latin-1"))
1763
+
1764
+
1765
+ def expand_tr_set(spec: str) -> str:
1766
+ result: list[str] = []
1767
+ index = 0
1768
+ while index < len(spec):
1769
+ first, index = read_tr_unit(spec, index)
1770
+ if index < len(spec) - 1 and spec[index] == "-" and len(first) == 1:
1771
+ second, next_index = read_tr_unit(spec, index + 1)
1772
+ if len(second) == 1:
1773
+ start = ord(first)
1774
+ end = ord(second)
1775
+ step = 1 if start <= end else -1
1776
+ result.extend(chr(value) for value in range(start, end + step, step))
1777
+ index = next_index
1778
+ continue
1779
+ result.extend(first)
1780
+ return "".join(result)
1781
+
1782
+
1783
+ def read_tr_unit(spec: str, index: int) -> tuple[str, int]:
1784
+ if spec.startswith("[:", index):
1785
+ end = spec.find(":]", index + 2)
1786
+ if end != -1:
1787
+ name = spec[index + 2 : end]
1788
+ return tr_character_class(name), end + 2
1789
+ char = spec[index]
1790
+ if char != "\\":
1791
+ return char, index + 1
1792
+ if index + 1 >= len(spec):
1793
+ return "\\", index + 1
1794
+ marker = spec[index + 1]
1795
+ escapes = {
1796
+ "a": "\a",
1797
+ "b": "\b",
1798
+ "f": "\f",
1799
+ "n": "\n",
1800
+ "r": "\r",
1801
+ "t": "\t",
1802
+ "v": "\v",
1803
+ "\\": "\\",
1804
+ }
1805
+ if marker in escapes:
1806
+ return escapes[marker], index + 2
1807
+ if marker in "01234567":
1808
+ end = index + 1
1809
+ while end < len(spec) and end < index + 4 and spec[end] in "01234567":
1810
+ end += 1
1811
+ return chr(int(spec[index + 1 : end], 8)), end
1812
+ return marker, index + 2
1813
+
1814
+
1815
+ def tr_character_class(name: str) -> str:
1816
+ classes = {
1817
+ "alnum": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
1818
+ "alpha": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
1819
+ "blank": " \t",
1820
+ "digit": "0123456789",
1821
+ "lower": "abcdefghijklmnopqrstuvwxyz",
1822
+ "space": " \t\r\n\v\f",
1823
+ "upper": "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
1824
+ "xdigit": "0123456789ABCDEFabcdef",
1825
+ }
1826
+ return classes.get(name, "")
1827
+
1828
+
1829
+ def squeeze_repeated_chars(text: str, chars: set[str]) -> str:
1830
+ if not text or not chars:
1831
+ return text
1832
+ result: list[str] = []
1833
+ previous = ""
1834
+ for char in text:
1835
+ if char == previous and char in chars:
1836
+ continue
1837
+ result.append(char)
1838
+ previous = char
1839
+ return "".join(result)
1840
+
1841
+
1842
+ def owner_group_names(info: os.stat_result) -> tuple[str, str]:
1843
+ owner = str(getattr(info, "st_uid", "user"))
1844
+ group = str(getattr(info, "st_gid", "group"))
1845
+ if pwd is not None:
1846
+ try:
1847
+ owner = pwd.getpwuid(info.st_uid).pw_name
1848
+ except (KeyError, AttributeError):
1849
+ pass
1850
+ else:
1851
+ try:
1852
+ owner = getpass.getuser()
1853
+ except OSError:
1854
+ owner = "user"
1855
+ if grp is not None:
1856
+ try:
1857
+ group = grp.getgrgid(info.st_gid).gr_name
1858
+ except (KeyError, AttributeError):
1859
+ pass
1860
+ elif not group or group.isdigit():
1861
+ group = owner
1862
+ return owner.replace(" ", "_"), group.replace(" ", "_")
1863
+
1864
+
1619
1865
  def split_owner_group(spec: str) -> tuple[str | None, str | None]:
1620
1866
  separator = ":" if ":" in spec else "." if "." in spec else ""
1621
1867
  if not separator:
@@ -2344,10 +2590,12 @@ def sys_argv0() -> str:
2344
2590
  INTERNAL_UTILITIES: dict[str, Utility] = {
2345
2591
  "awk": utility_gawk,
2346
2592
  "basename": utility_basename,
2593
+ "base64": utility_base64,
2347
2594
  "cat": utility_cat,
2348
2595
  "chmod": utility_chmod,
2349
2596
  "chgrp": utility_chgrp,
2350
2597
  "chown": utility_chown,
2598
+ "clear": utility_clear,
2351
2599
  "cmp": utility_cmp,
2352
2600
  "cp": utility_cp,
2353
2601
  "cut": utility_cut,
@@ -2382,6 +2630,7 @@ INTERNAL_UTILITIES: dict[str, Utility] = {
2382
2630
  "tail": utility_tail,
2383
2631
  "tar": utility_tar,
2384
2632
  "touch": utility_touch,
2633
+ "tr": utility_tr,
2385
2634
  "uname": utility_uname,
2386
2635
  "uniq": utility_uniq,
2387
2636
  "unlink": utility_unlink,
@@ -17,7 +17,7 @@ from typing import Iterator, TextIO
17
17
 
18
18
  from .builtins import BUILTINS, SPECIAL_BUILTINS
19
19
  from .errors import ExpansionError, LexerError, ParseError, ShellBreak, ShellContinue, ShellExit, ShellReturn
20
- from .expansion import expand_assignment, expand_here_document, expand_redirection, expand_word
20
+ from .expansion import chars_to_text, expand_assignment, expand_here_document, expand_redirection, expand_text, expand_word
21
21
  from .lexer import Operator, Word, lex
22
22
  from .parser import (
23
23
  AndOrList,
@@ -791,6 +791,7 @@ class Shell:
791
791
  opened: list[TextIO] = []
792
792
  try:
793
793
  for fd, op, target in redirections:
794
+ target = normalize_redirection_target(target)
794
795
  if op in {"<", "<&"} and op == "<":
795
796
  file = open(target, "r", encoding="utf-8", errors="replace")
796
797
  opened.append(file)
@@ -838,7 +839,20 @@ class Shell:
838
839
  status = 0
839
840
  buffer = ""
840
841
  while True:
841
- prompt = self.get_parameter("PS2") if buffer else self.get_parameter("PS1") or "$ "
842
+ prompt_source = self.get_parameter("PS2") if buffer else self.get_parameter("PS1") or "$ "
843
+ try:
844
+ prompt = self.expand_prompt(prompt_source)
845
+ except KeyboardInterrupt:
846
+ self.stdout.write("\n")
847
+ buffer = ""
848
+ status = 130
849
+ self.last_status = status
850
+ continue
851
+ except ExpansionError as exc:
852
+ self.stderr.write(f"{self.argv0}: {exc}\n")
853
+ prompt = prompt_source
854
+ status = 2
855
+ self.last_status = status
842
856
  try:
843
857
  line = input(prompt)
844
858
  except EOFError:
@@ -860,6 +874,13 @@ class Shell:
860
874
  self.stderr.write(f"{self.argv0}: {exc}\n")
861
875
  buffer = ""
862
876
  status = 2
877
+ self.last_status = status
878
+ continue
879
+ except ParseError as exc:
880
+ self.stderr.write(f"{self.argv0}: syntax error: {exc}\n")
881
+ buffer = ""
882
+ status = 2
883
+ self.last_status = status
863
884
  continue
864
885
  except KeyboardInterrupt:
865
886
  self.stdout.write("\n")
@@ -877,6 +898,9 @@ class Shell:
877
898
  self.last_status = status
878
899
  buffer = ""
879
900
 
901
+ def expand_prompt(self, prompt: str) -> str:
902
+ return chars_to_text(expand_text(self, prompt, quoted=True))
903
+
880
904
  def get_parameter(self, name: str, *, strict: bool = False) -> str:
881
905
  if name == "?":
882
906
  return str(self.last_status)
@@ -971,6 +995,12 @@ def has_fileno(stream: TextIO) -> bool:
971
995
  return True
972
996
 
973
997
 
998
+ def normalize_redirection_target(target: str) -> str:
999
+ if os.name == "nt" and target == "/dev/null":
1000
+ return os.devnull
1001
+ return target
1002
+
1003
+
974
1004
  def stop_process_after_interrupt(process: subprocess.Popen[str]) -> None:
975
1005
  try:
976
1006
  process.wait(timeout=0.2)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  import io
4
5
  import os
5
6
  import sys
@@ -394,6 +395,141 @@ def test_repl_keyboard_interrupt_returns_to_prompt(monkeypatch):
394
395
  assert stdout.getvalue() == "\nafter:130\n\n"
395
396
 
396
397
 
398
+ def test_repl_syntax_error_reports_and_keeps_session_alive(monkeypatch):
399
+ stdout = io.StringIO()
400
+ stderr = io.StringIO()
401
+ shell = Shell(stdout=stdout, stderr=stderr, interactive=True)
402
+ events = iter([">", "echo |", "echo after:$?", EOFError])
403
+
404
+ def fake_input(_prompt):
405
+ event = next(events)
406
+ if isinstance(event, type) and issubclass(event, BaseException):
407
+ raise event
408
+ return event
409
+
410
+ monkeypatch.setattr("builtins.input", fake_input)
411
+
412
+ assert shell.repl() == 0
413
+ assert shell.last_status == 0
414
+ assert stdout.getvalue() == "after:2\n\n"
415
+ errors = stderr.getvalue()
416
+ assert "pysh: syntax error:" in errors
417
+ assert "expected word" in errors
418
+ assert "expected command after pipe" in errors
419
+
420
+
421
+ def test_py_web_ssh_cwd_prompt_injection_without_native_utilities(monkeypatch, tmp_path):
422
+ token = "testtoken"
423
+ stdout = io.StringIO()
424
+ stderr = io.StringIO()
425
+ old_cwd = os.getcwd()
426
+ os.chdir(tmp_path)
427
+ try:
428
+ (tmp_path / "visible.txt").write_text("visible\n", encoding="utf-8")
429
+ shell = Shell(stdout=stdout, stderr=stderr, env={"PATH": ""}, interactive=True)
430
+ commands = (
431
+ "__py_web_ssh_cwd_armed=; "
432
+ "__py_web_ssh_cwd_ready(){ "
433
+ "[ -n \"${__py_web_ssh_cwd_armed-}\" ] || return; "
434
+ "printf '\\033]6970;ready;testtoken=1\\007' >&2; "
435
+ "}",
436
+ "__py_web_ssh_cwd_list(){ "
437
+ "[ -n \"${__py_web_ssh_cwd_armed-}\" ] || return; "
438
+ "__py_web_ssh_cwd_ls=$(LC_ALL=C command ls -al 2>&1 | command base64 | command tr -d '\\r\\n') || __py_web_ssh_cwd_ls=; "
439
+ "printf '\\033]6970;ls;testtoken=%s\\007' \"$__py_web_ssh_cwd_ls\" >&2; "
440
+ "}",
441
+ "__py_web_ssh_cwd_report(){ "
442
+ "[ -n \"${__py_web_ssh_cwd_armed-}\" ] || return; "
443
+ "__py_web_ssh_cwd_now=$(command pwd 2>/dev/null || printf '%s' \"$PWD\") || return; "
444
+ "if [ \"${__py_web_ssh_cwd_now}\" != \"${__py_web_ssh_cwd_last-}\" ]; then "
445
+ "__py_web_ssh_cwd_last=$__py_web_ssh_cwd_now; "
446
+ "printf '\\033]6970;cwd;testtoken=%s\\007' \"$__py_web_ssh_cwd_now\" >&2; "
447
+ "__py_web_ssh_cwd_list; "
448
+ "fi; "
449
+ "__py_web_ssh_cwd_ready; "
450
+ "}",
451
+ "if [ -n \"${BASH_VERSION:-}\" ]; then "
452
+ "PROMPT_COMMAND=\"__py_web_ssh_cwd_report${PROMPT_COMMAND:+;$PROMPT_COMMAND}\"; "
453
+ "elif [ -n \"${ZSH_VERSION:-}\" ]; then "
454
+ "autoload -Uz add-zsh-hook 2>/dev/null && add-zsh-hook precmd __py_web_ssh_cwd_report || "
455
+ "precmd_functions+=(__py_web_ssh_cwd_report); "
456
+ "else "
457
+ "__py_web_ssh_cwd_prompt=; "
458
+ "PS1='${__py_web_ssh_cwd_prompt:-$(__py_web_ssh_cwd_report)}'\"${PS1-}\"; "
459
+ "PS2='${__py_web_ssh_cwd_prompt:-$(__py_web_ssh_cwd_report)}'\"${PS2-}\"; "
460
+ "fi",
461
+ )
462
+ for command in commands:
463
+ assert shell.execute(command) == 0, stderr.getvalue()
464
+ assert shell.execute("__py_web_ssh_cwd_armed=1") == 0
465
+ stderr.seek(0)
466
+ stderr.truncate(0)
467
+ prompts: list[str] = []
468
+
469
+ def fake_input(prompt):
470
+ prompts.append(prompt)
471
+ raise EOFError
472
+
473
+ monkeypatch.setattr("builtins.input", fake_input)
474
+
475
+ assert shell.repl() == 0
476
+ hidden = stderr.getvalue()
477
+ assert prompts == [""]
478
+ assert f"\x1b]6970;cwd;{token}=".encode().decode() in hidden
479
+ assert f"\x1b]6970;ls;{token}=" in hidden
480
+ assert f"\x1b]6970;ready;{token}=1\x07" in hidden
481
+ listing_payload = hidden.split(f"\x1b]6970;ls;{token}=", 1)[1].split("\x07", 1)[0]
482
+ listing_text = base64.b64decode(listing_payload).decode("utf-8", errors="replace")
483
+ visible_line = next(line for line in listing_text.splitlines() if line.endswith(" visible.txt"))
484
+ assert len(visible_line.split(maxsplit=8)) == 9
485
+ finally:
486
+ os.chdir(old_cwd)
487
+
488
+
489
+ def test_py_web_ssh_base64_transfer_commands_without_native_utilities(tmp_path):
490
+ raw = b"\x00hello\r\nweb-ssh\xff"
491
+ source = tmp_path / "source.bin"
492
+ encoded = tmp_path / "source.b64"
493
+ decoded_d = tmp_path / "decoded-d.bin"
494
+ decoded_bsd = tmp_path / "decoded-bsd.bin"
495
+ size_path = tmp_path / "size.txt"
496
+ temp = tmp_path / "upload.tmp"
497
+ final = tmp_path / "final.bin"
498
+ b64_temp = tmp_path / "upload.b64"
499
+ err = tmp_path / "upload.tmp.err"
500
+ source.write_bytes(raw)
501
+ b64_temp.write_bytes(base64.b64encode(raw))
502
+ script = f'''
503
+ command base64 < "{source}" | command tr -d '\\r\\n' > "{encoded}"
504
+ command base64 -d < "{encoded}" > "{decoded_d}"
505
+ command base64 -D < "{encoded}" > "{decoded_bsd}"
506
+ if [ -f "{source}" ]; then wc -c < "{source}" > "{size_path}"; else exit 2; fi
507
+ set -e
508
+ rm -f "{temp}" "{err}"
509
+ if command base64 -d < "{b64_temp}" > "{temp}" 2> "{err}"; then
510
+ :
511
+ elif command base64 -D < "{b64_temp}" > "{temp}" 2> "{err}"; then
512
+ :
513
+ else
514
+ cat "{err}" >&2
515
+ exit 1
516
+ fi
517
+ mv -f "{temp}" "{final}"
518
+ rm -f "{b64_temp}" "{err}"
519
+ '''
520
+ status, stdout, stderr, _shell = run_shell(script, env={"PATH": ""})
521
+ assert status == 0
522
+ assert stdout == ""
523
+ assert stderr == ""
524
+ assert base64.b64decode(encoded.read_bytes()) == raw
525
+ assert decoded_d.read_bytes() == raw
526
+ assert decoded_bsd.read_bytes() == raw
527
+ assert int(size_path.read_text(encoding="utf-8").strip()) == len(raw)
528
+ assert final.read_bytes() == raw
529
+ assert not b64_temp.exists()
530
+ assert not err.exists()
531
+
532
+
397
533
  def test_internal_extended_text_utilities_without_path(tmp_path):
398
534
  data = tmp_path / "data.txt"
399
535
  csv = tmp_path / "data.csv"
@@ -423,6 +559,13 @@ yes ok | head -n 2
423
559
  assert stderr == ""
424
560
 
425
561
 
562
+ def test_internal_clear_utility_without_path():
563
+ status, stdout, stderr, _shell = run_shell("clear", env={"PATH": ""})
564
+ assert status == 0
565
+ assert stdout == "\033[H\033[2J"
566
+ assert stderr == ""
567
+
568
+
426
569
  def test_internal_findutils_and_file_install_without_path(tmp_path):
427
570
  source = tmp_path / "source.txt"
428
571
  source.write_text("payload\n", encoding="utf-8")
File without changes