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.
@@ -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.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
@@ -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.3"
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.3"
@@ -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, text in read_text_inputs(paths or ["-"], stdin, stderr, "wc"):
393
- if text is None:
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
- rows.append((text.count("\n"), len(text.split()), len(text.encode("utf-8")), None if path == "-" else path))
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.stat()
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
- return f"{mode} 1 {info.st_size:>8} {mtime} {name}"
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
- 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:
@@ -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