sdev 1.0.1__tar.gz → 1.0.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.
Files changed (34) hide show
  1. {sdev-1.0.1 → sdev-1.0.3}/PKG-INFO +1 -1
  2. {sdev-1.0.1 → sdev-1.0.3}/pyproject.toml +1 -1
  3. {sdev-1.0.1 → sdev-1.0.3}/sdev/__init__.py +130 -5
  4. {sdev-1.0.1 → sdev-1.0.3}/sdev.egg-info/PKG-INFO +1 -1
  5. {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_api_edge.py +1 -1
  6. {sdev-1.0.1 → sdev-1.0.3}/tests/test_cli_integration.py +2 -2
  7. {sdev-1.0.1 → sdev-1.0.3}/tests/test_sdev.py +56 -0
  8. {sdev-1.0.1 → sdev-1.0.3}/README.md +0 -0
  9. {sdev-1.0.1 → sdev-1.0.3}/sdev/__main__.py +0 -0
  10. {sdev-1.0.1 → sdev-1.0.3}/sdev.egg-info/SOURCES.txt +0 -0
  11. {sdev-1.0.1 → sdev-1.0.3}/sdev.egg-info/dependency_links.txt +0 -0
  12. {sdev-1.0.1 → sdev-1.0.3}/sdev.egg-info/entry_points.txt +0 -0
  13. {sdev-1.0.1 → sdev-1.0.3}/sdev.egg-info/requires.txt +0 -0
  14. {sdev-1.0.1 → sdev-1.0.3}/sdev.egg-info/top_level.txt +0 -0
  15. {sdev-1.0.1 → sdev-1.0.3}/setup.cfg +0 -0
  16. {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_ansi.py +0 -0
  17. {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_grep.py +0 -0
  18. {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_grep_linebyline.py +0 -0
  19. {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_interrupt.py +0 -0
  20. {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_module_api.py +0 -0
  21. {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_new_features.py +0 -0
  22. {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_run_connect.py +0 -0
  23. {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_serial_error.py +0 -0
  24. {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_strip.py +0 -0
  25. {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_timeout.py +0 -0
  26. {sdev-1.0.1 → sdev-1.0.3}/tests/test_doctor_silence.py +0 -0
  27. {sdev-1.0.1 → sdev-1.0.3}/tests/test_doctor_wait_silence.py +0 -0
  28. {sdev-1.0.1 → sdev-1.0.3}/tests/test_endflag_linemode.py +0 -0
  29. {sdev-1.0.1 → sdev-1.0.3}/tests/test_error_handling.py +0 -0
  30. {sdev-1.0.1 → sdev-1.0.3}/tests/test_probe.py +0 -0
  31. {sdev-1.0.1 → sdev-1.0.3}/tests/test_resource_usage.py +0 -0
  32. {sdev-1.0.1 → sdev-1.0.3}/tests/test_serial_lock.py +0 -0
  33. {sdev-1.0.1 → sdev-1.0.3}/tests/test_write.py +0 -0
  34. {sdev-1.0.1 → sdev-1.0.3}/tests/test_xc01_real.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sdev
3
- Version: 1.0.1
3
+ Version: 1.0.3
4
4
  Summary: Small toolkit for automating a serial-attached Linux shell
5
5
  Author-email: klrc <1440698245@qq.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sdev"
7
- version = "1.0.1"
7
+ version = "1.0.3"
8
8
  description = "Small toolkit for automating a serial-attached Linux shell"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -21,13 +21,14 @@ CLI::
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
- __version__ = "1.0.1"
24
+ __version__ = "1.0.3"
25
25
 
26
26
  import time
27
27
  import re
28
28
  import os
29
29
  import sys
30
30
  import glob
31
+ import shlex
31
32
  import serial
32
33
  import threading
33
34
  from pathlib import Path
@@ -83,6 +84,9 @@ CONFIG_FILE = CONFIG_DIR / "defaults.json"
83
84
  MAX_BUFFER_SIZE = 65536 # 64KB
84
85
  TRIM_BUFFER_SIZE = 32768 # Keep last 32KB after trim
85
86
 
87
+ NETWORK_GUARD_MARKER = "[sdev network guard]"
88
+ _NETWORK_GUARD_DISABLE_VALUES = {"0", "false", "False", "no", "NO", "off", "OFF"}
89
+
86
90
 
87
91
  # ---------------------------------------------------------------------------
88
92
  # Data types
@@ -154,6 +158,113 @@ def _prompt_detected(buf: bytes) -> bool:
154
158
  return False
155
159
 
156
160
 
161
+ _RISKY_NETWORK_RE = re.compile(
162
+ r"""
163
+ (?:
164
+ ifconfig\s+\S+\s+(?:down|up|hw\s+ether|(?:\d{1,3}\.){3}\d{1,3}|netmask)
165
+ | ip\s+(?:addr|address|link|route)\b[^\n;&|]*\b(?:add|del|delete|flush|set|replace|change|down|up)\b
166
+ | route\s+(?:add|del|delete)\b
167
+ | udhcpc\b
168
+ | dhclient\b
169
+ | nmcli\b
170
+ | netplan\s+apply\b
171
+ | /etc/init\.d/network(?:ing)?\b
172
+ | service\s+network(?:ing)?\b
173
+ | systemctl\s+(?:restart|reload|stop|start)\s+network(?:ing)?\b
174
+ )
175
+ """,
176
+ re.VERBOSE,
177
+ )
178
+
179
+ _GLOBAL_NETWORK_RE = re.compile(
180
+ r"""
181
+ \b(
182
+ ip\s+route\s+(?:flush|replace|change|del|delete)\b
183
+ | route\s+(?:add|del|delete)\b
184
+ | udhcpc\b
185
+ | dhclient\b
186
+ | nmcli\b
187
+ | netplan\s+apply\b
188
+ | /etc/init\.d/network(?:ing)?\b
189
+ | service\s+network(?:ing)?\b
190
+ | systemctl\s+(?:restart|reload|stop|start)\s+network(?:ing)?\b
191
+ )
192
+ """,
193
+ re.VERBOSE,
194
+ )
195
+
196
+
197
+ def _extract_network_ifaces(command: str) -> set[str]:
198
+ """Best-effort extraction of interfaces modified by a shell command."""
199
+ ifaces: set[str] = set()
200
+ iface = r"([A-Za-z0-9_.:-]+)"
201
+ patterns = [
202
+ rf"\bifconfig\s+{iface}\s+(?:down|up|hw\s+ether|(?:\d{{1,3}}\.){{3}}\d{{1,3}}|netmask)",
203
+ rf"\bip\s+link\s+set\s+(?:dev\s+)?{iface}\b",
204
+ rf"\bip\s+(?:addr|address)\b[^\n;&|]*\bdev\s+{iface}\b",
205
+ rf"\bip\s+route\b[^\n;&|]*\bdev\s+{iface}\b",
206
+ rf"\budhcpc\b[^\n;&|]*\s-i\s*{iface}\b",
207
+ rf"\bdhclient\b[^\n;&|]*\s{iface}\b",
208
+ ]
209
+ for pattern in patterns:
210
+ for match in re.finditer(pattern, command):
211
+ value = match.group(1)
212
+ if value and not value.startswith("$"):
213
+ ifaces.add(value)
214
+ return ifaces
215
+
216
+
217
+ def _network_guard_enabled(network_guard: Optional[bool]) -> bool:
218
+ if network_guard is not None:
219
+ return network_guard
220
+ return os.environ.get("SDEV_NETWORK_GUARD", "1") not in _NETWORK_GUARD_DISABLE_VALUES
221
+
222
+
223
+ def _guard_network_command(command: str, network_guard: Optional[bool]) -> str:
224
+ """Wrap risky network mutations with an NFS-route preflight."""
225
+ if not _network_guard_enabled(network_guard):
226
+ return command
227
+ if not _RISKY_NETWORK_RE.search(command):
228
+ return command
229
+
230
+ ifaces = sorted(_extract_network_ifaces(command))
231
+ global_risk = _GLOBAL_NETWORK_RE.search(command) is not None or not ifaces
232
+ iface_words = " ".join(ifaces)
233
+
234
+ return f"""__sdev_cmd={shlex.quote(command)}
235
+ __sdev_ifaces={shlex.quote(iface_words)}
236
+ __sdev_global={1 if global_risk else 0}
237
+ cd / || exit 125
238
+ __sdev_block=
239
+ __sdev_details=
240
+ while read -r __sdev_spec __sdev_mp __sdev_fstype __sdev_rest; do
241
+ case "$__sdev_fstype" in
242
+ nfs|nfs4)
243
+ __sdev_server="${{__sdev_spec%%:*}}"
244
+ __sdev_route="$(ip route get "$__sdev_server" 2>/dev/null || true)"
245
+ __sdev_dev="$(printf '%s\\n' "$__sdev_route" | sed -n 's/.* dev \\([^ ]*\\).*/\\1/p' | head -n 1)"
246
+ [ -n "$__sdev_dev" ] || continue
247
+ __sdev_details="$__sdev_details ${{__sdev_spec}} via ${{__sdev_dev}};"
248
+ if [ "$__sdev_global" = 1 ]; then
249
+ __sdev_block="network command may disrupt NFS mount(s)"
250
+ else
251
+ case " $__sdev_ifaces " in
252
+ *" $__sdev_dev "*) __sdev_block="target interface $__sdev_dev carries NFS" ;;
253
+ esac
254
+ fi
255
+ ;;
256
+ esac
257
+ done < /proc/mounts
258
+ if [ -n "$__sdev_block" ]; then
259
+ printf '%s refusing to run: %s. NFS route(s):%s Command: %s\\n' \\
260
+ {shlex.quote(NETWORK_GUARD_MARKER)} "$__sdev_block" "$__sdev_details" "$__sdev_cmd"
261
+ printf '%s\\n' 'Use network_guard=False or SDEV_NETWORK_GUARD=0 only if this network change is intentional.'
262
+ exit 125
263
+ fi
264
+ eval "$__sdev_cmd"
265
+ """
266
+
267
+
157
268
  # ---------------------------------------------------------------------------
158
269
  # SerialSession — explicit connection object (issue #3)
159
270
  # ---------------------------------------------------------------------------
@@ -459,6 +570,8 @@ class SerialSession:
459
570
  command: str,
460
571
  timeout: Optional[float] = None,
461
572
  end_flag: Optional[str] = None,
573
+ *,
574
+ network_guard: Optional[bool] = None,
462
575
  ) -> SerialResult:
463
576
  """Send *command* over serial and return its output.
464
577
 
@@ -468,13 +581,22 @@ class SerialSession:
468
581
  *end_flag*: a specific string to wait for instead of (or before) a
469
582
  shell prompt. Useful for commands that keep running after producing
470
583
  their result (e.g. benchmarks that print "Frame rate: ...").
584
+
585
+ *network_guard*: when enabled (default), risky network mutations are
586
+ refused if they could disconnect a currently mounted NFS filesystem.
471
587
  """
472
588
  if not self._lock.acquire(timeout=10):
473
589
  raise RuntimeError(
474
590
  "Serial port is busy — another command is in progress on this session."
475
591
  )
476
592
  try:
477
- return self._cli_impl(command, timeout, end_flag)
593
+ guarded_command = _guard_network_command(command, network_guard)
594
+ return self._cli_impl(
595
+ guarded_command,
596
+ timeout,
597
+ end_flag,
598
+ display_command=command,
599
+ )
478
600
  finally:
479
601
  self._lock.release()
480
602
 
@@ -483,6 +605,7 @@ class SerialSession:
483
605
  command: str,
484
606
  timeout: Optional[float],
485
607
  end_flag: Optional[str],
608
+ display_command: Optional[str] = None,
486
609
  ) -> SerialResult:
487
610
  ser = self._ensure_open()
488
611
  deadline = timeout or DEFAULT_TIMEOUT
@@ -507,7 +630,7 @@ class SerialSession:
507
630
  chunk = ser.read(4096)
508
631
  except serial.SerialException as exc:
509
632
  return SerialResult(
510
- command=command,
633
+ command=display_command or command,
511
634
  output=f"[sdev] serial error: {exc}",
512
635
  timed_out=True,
513
636
  elapsed=round(time.monotonic() - start, 2),
@@ -532,7 +655,7 @@ class SerialSession:
532
655
  clean = _strip_echo(clean, command)
533
656
  clean = _strip_prompt_instance(clean, self._prompts)
534
657
  return SerialResult(
535
- command=command,
658
+ command=display_command or command,
536
659
  output=clean.decode(errors="replace"),
537
660
  timed_out=timed_out,
538
661
  elapsed=round(elapsed, 2),
@@ -753,9 +876,11 @@ def cli(
753
876
  command: str,
754
877
  timeout: Optional[float] = None,
755
878
  end_flag: Optional[str] = None,
879
+ *,
880
+ network_guard: Optional[bool] = None,
756
881
  ) -> SerialResult:
757
882
  """Send *command* over the default connection and return output."""
758
- return _default_session.cli(command, timeout, end_flag)
883
+ return _default_session.cli(command, timeout, end_flag, network_guard=network_guard)
759
884
 
760
885
 
761
886
  def run(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sdev
3
- Version: 1.0.1
3
+ Version: 1.0.3
4
4
  Summary: Small toolkit for automating a serial-attached Linux shell
5
5
  Author-email: klrc <1440698245@qq.com>
6
6
  License-Expression: MIT
@@ -55,7 +55,7 @@ class TestModuleLevelAPIDelegation(unittest.TestCase):
55
55
  with patch.object(sdev, "_default_session") as mock_sess:
56
56
  mock_sess.cli.return_value = mock_result
57
57
  result = sdev.cli("echo hi")
58
- mock_sess.cli.assert_called_once_with("echo hi", None, None)
58
+ mock_sess.cli.assert_called_once_with("echo hi", None, None, network_guard=None)
59
59
  self.assertEqual(result.output, "hi\n")
60
60
 
61
61
  def test_module_stream_delegates(self):
@@ -324,7 +324,7 @@ class TestModuleLevelAPI(unittest.TestCase):
324
324
  with patch.object(sdev, "_default_session", mock_sess):
325
325
  result = sdev.cli("echo x", timeout=5)
326
326
 
327
- mock_sess.cli.assert_called_once_with("echo x", 5, None)
327
+ mock_sess.cli.assert_called_once_with("echo x", 5, None, network_guard=None)
328
328
  self.assertEqual(result.output, "x\n")
329
329
 
330
330
  def test_module_cli_passes_end_flag(self):
@@ -336,7 +336,7 @@ class TestModuleLevelAPI(unittest.TestCase):
336
336
  with patch.object(sdev, "_default_session", mock_sess):
337
337
  sdev.cli("bench", end_flag="Frame rate:")
338
338
 
339
- mock_sess.cli.assert_called_once_with("bench", None, "Frame rate:")
339
+ mock_sess.cli.assert_called_once_with("bench", None, "Frame rate:", network_guard=None)
340
340
 
341
341
  def test_module_connect_delegates(self):
342
342
  """sdev.connect() should call default session's connect()."""
@@ -119,6 +119,62 @@ class TestSerialSession(unittest.TestCase):
119
119
  self.assertTrue(result.timed_out)
120
120
  self.assertGreater(result.elapsed, 0.15)
121
121
 
122
+ def test_cli_leaves_non_network_command_unwrapped(self):
123
+ mock_ser = MagicMock()
124
+ mock_ser.is_open = True
125
+ mock_ser.read.side_effect = [b"hello\n# ", b""]
126
+
127
+ sess = sdev.SerialSession()
128
+ sess._connection = mock_ser
129
+
130
+ result = sess.cli("echo hello")
131
+ self.assertEqual(result.command, "echo hello")
132
+ mock_ser.write.assert_called_once_with(b"echo hello\n")
133
+
134
+ def test_cli_wraps_risky_network_command(self):
135
+ mock_ser = MagicMock()
136
+ mock_ser.is_open = True
137
+ mock_ser.read.side_effect = [
138
+ (
139
+ b"[sdev network guard] refusing to run: target interface eth0 "
140
+ b"carries NFS. NFS route(s): 192.168.1.11:/nfs via eth0; "
141
+ b"Command: ifconfig eth0 down\n"
142
+ b"Use network_guard=False or SDEV_NETWORK_GUARD=0 only if this "
143
+ b"network change is intentional.\n# "
144
+ ),
145
+ b"",
146
+ ]
147
+
148
+ sess = sdev.SerialSession()
149
+ sess._connection = mock_ser
150
+
151
+ result = sess.cli("ifconfig eth0 down")
152
+ sent = mock_ser.write.call_args.args[0].decode()
153
+ self.assertEqual(result.command, "ifconfig eth0 down")
154
+ self.assertIn("cd / || exit 125", sent)
155
+ self.assertIn("/proc/mounts", sent)
156
+ self.assertIn("ip route get", sent)
157
+ self.assertIn("ifconfig eth0 down", sent)
158
+ self.assertIn("[sdev network guard] refusing to run", result.output)
159
+ self.assertIn("target interface eth0 carries NFS", result.output)
160
+
161
+ def test_cli_network_guard_can_be_disabled_per_call(self):
162
+ mock_ser = MagicMock()
163
+ mock_ser.is_open = True
164
+ mock_ser.read.side_effect = [b"ok\n# ", b""]
165
+
166
+ sess = sdev.SerialSession()
167
+ sess._connection = mock_ser
168
+
169
+ sess.cli("ifconfig eth0 down", network_guard=False)
170
+ mock_ser.write.assert_called_once_with(b"ifconfig eth0 down\n")
171
+
172
+ def test_variable_iface_network_command_is_guarded_globally(self):
173
+ command = 'for iface in eth0 eth1; do ifconfig $iface down; done'
174
+ wrapped = sdev._guard_network_command(command, network_guard=True)
175
+ self.assertIn("__sdev_global=1", wrapped)
176
+ self.assertIn("network command may disrupt NFS mount(s)", wrapped)
177
+
122
178
  def test_stream_yields_chunks(self):
123
179
  mock_ser = MagicMock()
124
180
  mock_ser.is_open = True
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes