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.
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.4}/PKG-INFO +3 -3
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.4}/README.md +2 -2
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.4}/pyproject.toml +1 -1
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.4}/src/py_posix_shell/__init__.py +1 -1
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.4}/src/py_posix_shell/parser.py +20 -1
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.4}/src/py_posix_shell/posix_utils.py +255 -6
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.4}/src/py_posix_shell/shell.py +32 -2
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.4}/tests/test_shell.py +143 -0
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.4}/.gitignore +0 -0
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.4}/LICENSE +0 -0
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.4}/src/py_posix_shell/__main__.py +0 -0
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.4}/src/py_posix_shell/builtins.py +0 -0
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.4}/src/py_posix_shell/cli.py +0 -0
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.4}/src/py_posix_shell/errors.py +0 -0
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.4}/src/py_posix_shell/expansion.py +0 -0
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.4}/src/py_posix_shell/lexer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: py-posix-shell
|
|
3
|
-
Version: 0.4.
|
|
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
|
|
@@ -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
|
|
393
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|