py-posix-shell 0.4.2__tar.gz → 0.4.3__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.3}/PKG-INFO +2 -2
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.3}/README.md +1 -1
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.3}/pyproject.toml +1 -1
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.3}/src/py_posix_shell/__init__.py +1 -1
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.3}/src/py_posix_shell/parser.py +20 -1
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.3}/src/py_posix_shell/posix_utils.py +227 -6
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.3}/src/py_posix_shell/shell.py +25 -2
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.3}/tests/test_shell.py +113 -0
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.3}/.gitignore +0 -0
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.3}/LICENSE +0 -0
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.3}/src/py_posix_shell/__main__.py +0 -0
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.3}/src/py_posix_shell/builtins.py +0 -0
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.3}/src/py_posix_shell/cli.py +0 -0
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.3}/src/py_posix_shell/errors.py +0 -0
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.3}/src/py_posix_shell/expansion.py +0 -0
- {py_posix_shell-0.4.2 → py_posix_shell-0.4.3}/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.3
|
|
4
4
|
Summary: A cross-platform POSIX-style shell written in Python.
|
|
5
5
|
Author: GGN_2015
|
|
6
6
|
License-Expression: MIT
|
|
@@ -58,7 +58,7 @@ scripts:
|
|
|
58
58
|
`sort`, `uniq`, `wc`, `cut`, `head`, `tail`, `chmod`, `chown`, `chgrp`,
|
|
59
59
|
`echo`, `date`, `sleep`, `printf`, `basename`, `dirname`, `whoami`, `yes`,
|
|
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
|
|
@@ -35,7 +35,7 @@ scripts:
|
|
|
35
35
|
`sort`, `uniq`, `wc`, `cut`, `head`, `tail`, `chmod`, `chown`, `chgrp`,
|
|
36
36
|
`echo`, `date`, `sleep`, `printf`, `basename`, `dirname`, `whoami`, `yes`,
|
|
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,97 @@ 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
|
+
|
|
54
147
|
def utility_ls(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int:
|
|
55
148
|
show_all = False
|
|
56
149
|
almost_all = False
|
|
@@ -389,11 +482,15 @@ def utility_wc(shell, argv: list[str], stdin: TextIO, stdout: TextIO, stderr: Te
|
|
|
389
482
|
|
|
390
483
|
rows: list[tuple[int, int, int, str | None]] = []
|
|
391
484
|
status = 0
|
|
392
|
-
for path
|
|
393
|
-
|
|
485
|
+
for path in paths or ["-"]:
|
|
486
|
+
try:
|
|
487
|
+
data = next(iter(read_binary_inputs([path], stdin)))
|
|
488
|
+
except OSError as exc:
|
|
489
|
+
stderr.write(f"wc: {path}: {exc.strerror or exc}\n")
|
|
394
490
|
status = 1
|
|
395
491
|
continue
|
|
396
|
-
|
|
492
|
+
text = data.decode("utf-8", errors="replace")
|
|
493
|
+
rows.append((data.count(b"\n"), len(text.split()), len(data), None if path == "-" else path))
|
|
397
494
|
for lines, words, bytes_count, name in rows:
|
|
398
495
|
values: list[str] = []
|
|
399
496
|
if count_lines:
|
|
@@ -1518,12 +1615,13 @@ def display_name(entry: Path, base: Path) -> str:
|
|
|
1518
1615
|
|
|
1519
1616
|
def format_long_listing(path: Path, name: str) -> str:
|
|
1520
1617
|
try:
|
|
1521
|
-
info = path.
|
|
1618
|
+
info = path.lstat()
|
|
1522
1619
|
except OSError:
|
|
1523
|
-
return f"?????????? 0 {name}"
|
|
1620
|
+
return f"?????????? 0 unknown unknown 0 Jan 01 00:00 {name}"
|
|
1524
1621
|
mode = stat.filemode(info.st_mode)
|
|
1525
1622
|
mtime = _datetime.datetime.fromtimestamp(info.st_mtime).strftime("%b %d %H:%M")
|
|
1526
|
-
|
|
1623
|
+
owner, group = owner_group_names(info)
|
|
1624
|
+
return f"{mode} {getattr(info, 'st_nlink', 1):>2} {owner:<8} {group:<8} {info.st_size:>8} {mtime} {name}"
|
|
1527
1625
|
|
|
1528
1626
|
|
|
1529
1627
|
def parse_line_count_args(command: str, argv: list[str], stderr: TextIO, *, default: int) -> tuple[int, list[str], int]:
|
|
@@ -1616,6 +1714,127 @@ def parse_csv_line(line: str) -> list[str]:
|
|
|
1616
1714
|
return result
|
|
1617
1715
|
|
|
1618
1716
|
|
|
1717
|
+
def read_binary_inputs(paths: Iterable[str], stdin: TextIO) -> Iterable[bytes]:
|
|
1718
|
+
for path in paths:
|
|
1719
|
+
if path == "-":
|
|
1720
|
+
buffer = getattr(stdin, "buffer", None)
|
|
1721
|
+
if buffer is not None:
|
|
1722
|
+
yield buffer.read()
|
|
1723
|
+
else:
|
|
1724
|
+
yield stdin.read().encode("utf-8")
|
|
1725
|
+
continue
|
|
1726
|
+
yield Path(path).read_bytes()
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def write_binary_output(stdout: TextIO, data: bytes) -> None:
|
|
1730
|
+
buffer = getattr(stdout, "buffer", None)
|
|
1731
|
+
if buffer is not None:
|
|
1732
|
+
buffer.write(data)
|
|
1733
|
+
buffer.flush()
|
|
1734
|
+
else:
|
|
1735
|
+
stdout.write(data.decode("latin-1"))
|
|
1736
|
+
|
|
1737
|
+
|
|
1738
|
+
def expand_tr_set(spec: str) -> str:
|
|
1739
|
+
result: list[str] = []
|
|
1740
|
+
index = 0
|
|
1741
|
+
while index < len(spec):
|
|
1742
|
+
first, index = read_tr_unit(spec, index)
|
|
1743
|
+
if index < len(spec) - 1 and spec[index] == "-" and len(first) == 1:
|
|
1744
|
+
second, next_index = read_tr_unit(spec, index + 1)
|
|
1745
|
+
if len(second) == 1:
|
|
1746
|
+
start = ord(first)
|
|
1747
|
+
end = ord(second)
|
|
1748
|
+
step = 1 if start <= end else -1
|
|
1749
|
+
result.extend(chr(value) for value in range(start, end + step, step))
|
|
1750
|
+
index = next_index
|
|
1751
|
+
continue
|
|
1752
|
+
result.extend(first)
|
|
1753
|
+
return "".join(result)
|
|
1754
|
+
|
|
1755
|
+
|
|
1756
|
+
def read_tr_unit(spec: str, index: int) -> tuple[str, int]:
|
|
1757
|
+
if spec.startswith("[:", index):
|
|
1758
|
+
end = spec.find(":]", index + 2)
|
|
1759
|
+
if end != -1:
|
|
1760
|
+
name = spec[index + 2 : end]
|
|
1761
|
+
return tr_character_class(name), end + 2
|
|
1762
|
+
char = spec[index]
|
|
1763
|
+
if char != "\\":
|
|
1764
|
+
return char, index + 1
|
|
1765
|
+
if index + 1 >= len(spec):
|
|
1766
|
+
return "\\", index + 1
|
|
1767
|
+
marker = spec[index + 1]
|
|
1768
|
+
escapes = {
|
|
1769
|
+
"a": "\a",
|
|
1770
|
+
"b": "\b",
|
|
1771
|
+
"f": "\f",
|
|
1772
|
+
"n": "\n",
|
|
1773
|
+
"r": "\r",
|
|
1774
|
+
"t": "\t",
|
|
1775
|
+
"v": "\v",
|
|
1776
|
+
"\\": "\\",
|
|
1777
|
+
}
|
|
1778
|
+
if marker in escapes:
|
|
1779
|
+
return escapes[marker], index + 2
|
|
1780
|
+
if marker in "01234567":
|
|
1781
|
+
end = index + 1
|
|
1782
|
+
while end < len(spec) and end < index + 4 and spec[end] in "01234567":
|
|
1783
|
+
end += 1
|
|
1784
|
+
return chr(int(spec[index + 1 : end], 8)), end
|
|
1785
|
+
return marker, index + 2
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
def tr_character_class(name: str) -> str:
|
|
1789
|
+
classes = {
|
|
1790
|
+
"alnum": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
|
|
1791
|
+
"alpha": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
|
|
1792
|
+
"blank": " \t",
|
|
1793
|
+
"digit": "0123456789",
|
|
1794
|
+
"lower": "abcdefghijklmnopqrstuvwxyz",
|
|
1795
|
+
"space": " \t\r\n\v\f",
|
|
1796
|
+
"upper": "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
|
1797
|
+
"xdigit": "0123456789ABCDEFabcdef",
|
|
1798
|
+
}
|
|
1799
|
+
return classes.get(name, "")
|
|
1800
|
+
|
|
1801
|
+
|
|
1802
|
+
def squeeze_repeated_chars(text: str, chars: set[str]) -> str:
|
|
1803
|
+
if not text or not chars:
|
|
1804
|
+
return text
|
|
1805
|
+
result: list[str] = []
|
|
1806
|
+
previous = ""
|
|
1807
|
+
for char in text:
|
|
1808
|
+
if char == previous and char in chars:
|
|
1809
|
+
continue
|
|
1810
|
+
result.append(char)
|
|
1811
|
+
previous = char
|
|
1812
|
+
return "".join(result)
|
|
1813
|
+
|
|
1814
|
+
|
|
1815
|
+
def owner_group_names(info: os.stat_result) -> tuple[str, str]:
|
|
1816
|
+
owner = str(getattr(info, "st_uid", "user"))
|
|
1817
|
+
group = str(getattr(info, "st_gid", "group"))
|
|
1818
|
+
if pwd is not None:
|
|
1819
|
+
try:
|
|
1820
|
+
owner = pwd.getpwuid(info.st_uid).pw_name
|
|
1821
|
+
except (KeyError, AttributeError):
|
|
1822
|
+
pass
|
|
1823
|
+
else:
|
|
1824
|
+
try:
|
|
1825
|
+
owner = getpass.getuser()
|
|
1826
|
+
except OSError:
|
|
1827
|
+
owner = "user"
|
|
1828
|
+
if grp is not None:
|
|
1829
|
+
try:
|
|
1830
|
+
group = grp.getgrgid(info.st_gid).gr_name
|
|
1831
|
+
except (KeyError, AttributeError):
|
|
1832
|
+
pass
|
|
1833
|
+
elif not group or group.isdigit():
|
|
1834
|
+
group = owner
|
|
1835
|
+
return owner.replace(" ", "_"), group.replace(" ", "_")
|
|
1836
|
+
|
|
1837
|
+
|
|
1619
1838
|
def split_owner_group(spec: str) -> tuple[str | None, str | None]:
|
|
1620
1839
|
separator = ":" if ":" in spec else "." if "." in spec else ""
|
|
1621
1840
|
if not separator:
|
|
@@ -2344,6 +2563,7 @@ def sys_argv0() -> str:
|
|
|
2344
2563
|
INTERNAL_UTILITIES: dict[str, Utility] = {
|
|
2345
2564
|
"awk": utility_gawk,
|
|
2346
2565
|
"basename": utility_basename,
|
|
2566
|
+
"base64": utility_base64,
|
|
2347
2567
|
"cat": utility_cat,
|
|
2348
2568
|
"chmod": utility_chmod,
|
|
2349
2569
|
"chgrp": utility_chgrp,
|
|
@@ -2382,6 +2602,7 @@ INTERNAL_UTILITIES: dict[str, Utility] = {
|
|
|
2382
2602
|
"tail": utility_tail,
|
|
2383
2603
|
"tar": utility_tar,
|
|
2384
2604
|
"touch": utility_touch,
|
|
2605
|
+
"tr": utility_tr,
|
|
2385
2606
|
"uname": utility_uname,
|
|
2386
2607
|
"uniq": utility_uniq,
|
|
2387
2608
|
"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:
|
|
@@ -877,6 +891,9 @@ class Shell:
|
|
|
877
891
|
self.last_status = status
|
|
878
892
|
buffer = ""
|
|
879
893
|
|
|
894
|
+
def expand_prompt(self, prompt: str) -> str:
|
|
895
|
+
return chars_to_text(expand_text(self, prompt, quoted=True))
|
|
896
|
+
|
|
880
897
|
def get_parameter(self, name: str, *, strict: bool = False) -> str:
|
|
881
898
|
if name == "?":
|
|
882
899
|
return str(self.last_status)
|
|
@@ -971,6 +988,12 @@ def has_fileno(stream: TextIO) -> bool:
|
|
|
971
988
|
return True
|
|
972
989
|
|
|
973
990
|
|
|
991
|
+
def normalize_redirection_target(target: str) -> str:
|
|
992
|
+
if os.name == "nt" and target == "/dev/null":
|
|
993
|
+
return os.devnull
|
|
994
|
+
return target
|
|
995
|
+
|
|
996
|
+
|
|
974
997
|
def stop_process_after_interrupt(process: subprocess.Popen[str]) -> None:
|
|
975
998
|
try:
|
|
976
999
|
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,118 @@ 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_py_web_ssh_cwd_prompt_injection_without_native_utilities(monkeypatch, tmp_path):
|
|
399
|
+
token = "testtoken"
|
|
400
|
+
stdout = io.StringIO()
|
|
401
|
+
stderr = io.StringIO()
|
|
402
|
+
old_cwd = os.getcwd()
|
|
403
|
+
os.chdir(tmp_path)
|
|
404
|
+
try:
|
|
405
|
+
(tmp_path / "visible.txt").write_text("visible\n", encoding="utf-8")
|
|
406
|
+
shell = Shell(stdout=stdout, stderr=stderr, env={"PATH": ""}, interactive=True)
|
|
407
|
+
commands = (
|
|
408
|
+
"__py_web_ssh_cwd_armed=; "
|
|
409
|
+
"__py_web_ssh_cwd_ready(){ "
|
|
410
|
+
"[ -n \"${__py_web_ssh_cwd_armed-}\" ] || return; "
|
|
411
|
+
"printf '\\033]6970;ready;testtoken=1\\007' >&2; "
|
|
412
|
+
"}",
|
|
413
|
+
"__py_web_ssh_cwd_list(){ "
|
|
414
|
+
"[ -n \"${__py_web_ssh_cwd_armed-}\" ] || return; "
|
|
415
|
+
"__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=; "
|
|
416
|
+
"printf '\\033]6970;ls;testtoken=%s\\007' \"$__py_web_ssh_cwd_ls\" >&2; "
|
|
417
|
+
"}",
|
|
418
|
+
"__py_web_ssh_cwd_report(){ "
|
|
419
|
+
"[ -n \"${__py_web_ssh_cwd_armed-}\" ] || return; "
|
|
420
|
+
"__py_web_ssh_cwd_now=$(command pwd 2>/dev/null || printf '%s' \"$PWD\") || return; "
|
|
421
|
+
"if [ \"${__py_web_ssh_cwd_now}\" != \"${__py_web_ssh_cwd_last-}\" ]; then "
|
|
422
|
+
"__py_web_ssh_cwd_last=$__py_web_ssh_cwd_now; "
|
|
423
|
+
"printf '\\033]6970;cwd;testtoken=%s\\007' \"$__py_web_ssh_cwd_now\" >&2; "
|
|
424
|
+
"__py_web_ssh_cwd_list; "
|
|
425
|
+
"fi; "
|
|
426
|
+
"__py_web_ssh_cwd_ready; "
|
|
427
|
+
"}",
|
|
428
|
+
"if [ -n \"${BASH_VERSION:-}\" ]; then "
|
|
429
|
+
"PROMPT_COMMAND=\"__py_web_ssh_cwd_report${PROMPT_COMMAND:+;$PROMPT_COMMAND}\"; "
|
|
430
|
+
"elif [ -n \"${ZSH_VERSION:-}\" ]; then "
|
|
431
|
+
"autoload -Uz add-zsh-hook 2>/dev/null && add-zsh-hook precmd __py_web_ssh_cwd_report || "
|
|
432
|
+
"precmd_functions+=(__py_web_ssh_cwd_report); "
|
|
433
|
+
"else "
|
|
434
|
+
"__py_web_ssh_cwd_prompt=; "
|
|
435
|
+
"PS1='${__py_web_ssh_cwd_prompt:-$(__py_web_ssh_cwd_report)}'\"${PS1-}\"; "
|
|
436
|
+
"PS2='${__py_web_ssh_cwd_prompt:-$(__py_web_ssh_cwd_report)}'\"${PS2-}\"; "
|
|
437
|
+
"fi",
|
|
438
|
+
)
|
|
439
|
+
for command in commands:
|
|
440
|
+
assert shell.execute(command) == 0, stderr.getvalue()
|
|
441
|
+
assert shell.execute("__py_web_ssh_cwd_armed=1") == 0
|
|
442
|
+
stderr.seek(0)
|
|
443
|
+
stderr.truncate(0)
|
|
444
|
+
prompts: list[str] = []
|
|
445
|
+
|
|
446
|
+
def fake_input(prompt):
|
|
447
|
+
prompts.append(prompt)
|
|
448
|
+
raise EOFError
|
|
449
|
+
|
|
450
|
+
monkeypatch.setattr("builtins.input", fake_input)
|
|
451
|
+
|
|
452
|
+
assert shell.repl() == 0
|
|
453
|
+
hidden = stderr.getvalue()
|
|
454
|
+
assert prompts == [""]
|
|
455
|
+
assert f"\x1b]6970;cwd;{token}=".encode().decode() in hidden
|
|
456
|
+
assert f"\x1b]6970;ls;{token}=" in hidden
|
|
457
|
+
assert f"\x1b]6970;ready;{token}=1\x07" in hidden
|
|
458
|
+
listing_payload = hidden.split(f"\x1b]6970;ls;{token}=", 1)[1].split("\x07", 1)[0]
|
|
459
|
+
listing_text = base64.b64decode(listing_payload).decode("utf-8", errors="replace")
|
|
460
|
+
visible_line = next(line for line in listing_text.splitlines() if line.endswith(" visible.txt"))
|
|
461
|
+
assert len(visible_line.split(maxsplit=8)) == 9
|
|
462
|
+
finally:
|
|
463
|
+
os.chdir(old_cwd)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def test_py_web_ssh_base64_transfer_commands_without_native_utilities(tmp_path):
|
|
467
|
+
raw = b"\x00hello\r\nweb-ssh\xff"
|
|
468
|
+
source = tmp_path / "source.bin"
|
|
469
|
+
encoded = tmp_path / "source.b64"
|
|
470
|
+
decoded_d = tmp_path / "decoded-d.bin"
|
|
471
|
+
decoded_bsd = tmp_path / "decoded-bsd.bin"
|
|
472
|
+
size_path = tmp_path / "size.txt"
|
|
473
|
+
temp = tmp_path / "upload.tmp"
|
|
474
|
+
final = tmp_path / "final.bin"
|
|
475
|
+
b64_temp = tmp_path / "upload.b64"
|
|
476
|
+
err = tmp_path / "upload.tmp.err"
|
|
477
|
+
source.write_bytes(raw)
|
|
478
|
+
b64_temp.write_bytes(base64.b64encode(raw))
|
|
479
|
+
script = f'''
|
|
480
|
+
command base64 < "{source}" | command tr -d '\\r\\n' > "{encoded}"
|
|
481
|
+
command base64 -d < "{encoded}" > "{decoded_d}"
|
|
482
|
+
command base64 -D < "{encoded}" > "{decoded_bsd}"
|
|
483
|
+
if [ -f "{source}" ]; then wc -c < "{source}" > "{size_path}"; else exit 2; fi
|
|
484
|
+
set -e
|
|
485
|
+
rm -f "{temp}" "{err}"
|
|
486
|
+
if command base64 -d < "{b64_temp}" > "{temp}" 2> "{err}"; then
|
|
487
|
+
:
|
|
488
|
+
elif command base64 -D < "{b64_temp}" > "{temp}" 2> "{err}"; then
|
|
489
|
+
:
|
|
490
|
+
else
|
|
491
|
+
cat "{err}" >&2
|
|
492
|
+
exit 1
|
|
493
|
+
fi
|
|
494
|
+
mv -f "{temp}" "{final}"
|
|
495
|
+
rm -f "{b64_temp}" "{err}"
|
|
496
|
+
'''
|
|
497
|
+
status, stdout, stderr, _shell = run_shell(script, env={"PATH": ""})
|
|
498
|
+
assert status == 0
|
|
499
|
+
assert stdout == ""
|
|
500
|
+
assert stderr == ""
|
|
501
|
+
assert base64.b64decode(encoded.read_bytes()) == raw
|
|
502
|
+
assert decoded_d.read_bytes() == raw
|
|
503
|
+
assert decoded_bsd.read_bytes() == raw
|
|
504
|
+
assert int(size_path.read_text(encoding="utf-8").strip()) == len(raw)
|
|
505
|
+
assert final.read_bytes() == raw
|
|
506
|
+
assert not b64_temp.exists()
|
|
507
|
+
assert not err.exists()
|
|
508
|
+
|
|
509
|
+
|
|
397
510
|
def test_internal_extended_text_utilities_without_path(tmp_path):
|
|
398
511
|
data = tmp_path / "data.txt"
|
|
399
512
|
csv = tmp_path / "data.csv"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|