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.
- {sdev-1.0.1 → sdev-1.0.3}/PKG-INFO +1 -1
- {sdev-1.0.1 → sdev-1.0.3}/pyproject.toml +1 -1
- {sdev-1.0.1 → sdev-1.0.3}/sdev/__init__.py +130 -5
- {sdev-1.0.1 → sdev-1.0.3}/sdev.egg-info/PKG-INFO +1 -1
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_api_edge.py +1 -1
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_cli_integration.py +2 -2
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_sdev.py +56 -0
- {sdev-1.0.1 → sdev-1.0.3}/README.md +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/sdev/__main__.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/sdev.egg-info/SOURCES.txt +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/sdev.egg-info/dependency_links.txt +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/sdev.egg-info/entry_points.txt +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/sdev.egg-info/requires.txt +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/sdev.egg-info/top_level.txt +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/setup.cfg +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_ansi.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_grep.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_grep_linebyline.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_interrupt.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_module_api.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_new_features.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_run_connect.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_serial_error.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_strip.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_adversarial_timeout.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_doctor_silence.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_doctor_wait_silence.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_endflag_linemode.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_error_handling.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_probe.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_resource_usage.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_serial_lock.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_write.py +0 -0
- {sdev-1.0.1 → sdev-1.0.3}/tests/test_xc01_real.py +0 -0
|
@@ -21,13 +21,14 @@ CLI::
|
|
|
21
21
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
|
-
__version__ = "1.0.
|
|
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
|
-
|
|
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(
|
|
@@ -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
|
|
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
|
|
File without changes
|