embedded-lab-control 0.2.0__py3-none-any.whl
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.
- embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
- embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
- embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
- embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
- embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
- embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
- labctl/__init__.py +8 -0
- labctl/actuators/__init__.py +56 -0
- labctl/actuators/base.py +183 -0
- labctl/actuators/lcus1.py +232 -0
- labctl/actuators/mock.py +81 -0
- labctl/actuators/runtime.py +1178 -0
- labctl/cli.py +7485 -0
- labctl/core/__init__.py +36 -0
- labctl/core/admin_files.py +388 -0
- labctl/core/audit.py +269 -0
- labctl/core/claim_keepalive.py +342 -0
- labctl/core/config.py +1068 -0
- labctl/core/database.py +880 -0
- labctl/core/images.py +503 -0
- labctl/core/manager.py +2815 -0
- labctl/core/models.py +815 -0
- labctl/core/safe_open.py +109 -0
- labctl/extras.py +128 -0
- labctl/health/__init__.py +43 -0
- labctl/health/alerts.py +275 -0
- labctl/health/checks.py +533 -0
- labctl/health/daemon.py +397 -0
- labctl/mcp_server.py +5254 -0
- labctl/power/__init__.py +13 -0
- labctl/power/base.py +145 -0
- labctl/power/kasa.py +446 -0
- labctl/power/shelly.py +89 -0
- labctl/power/tasmota.py +95 -0
- labctl/sdwire/__init__.py +10 -0
- labctl/sdwire/controller.py +1116 -0
- labctl/sdwire/power_guard.py +190 -0
- labctl/serial/__init__.py +18 -0
- labctl/serial/boot_test.py +342 -0
- labctl/serial/capture.py +430 -0
- labctl/serial/keys.py +89 -0
- labctl/serial/proxy.py +802 -0
- labctl/serial/ser2net.py +338 -0
- labctl/serial/udev.py +273 -0
- labctl/services.py +210 -0
- labctl/web/__init__.py +9 -0
- labctl/web/activity_broadcaster.py +204 -0
- labctl/web/api.py +1003 -0
- labctl/web/app.py +202 -0
- labctl/web/auth.py +97 -0
- labctl/web/claims.py +232 -0
- labctl/web/static/css/style.css +477 -0
- labctl/web/static/js/app.js +76 -0
- labctl/web/templates/activity.html +202 -0
- labctl/web/templates/base.html +49 -0
- labctl/web/templates/claim_refused.html +38 -0
- labctl/web/templates/console.html +237 -0
- labctl/web/templates/dashboard.html +100 -0
- labctl/web/templates/login.html +25 -0
- labctl/web/templates/sbc_detail.html +306 -0
- labctl/web/templates/settings.html +78 -0
- labctl/web/templates/status_history.html +65 -0
- labctl/web/views.py +496 -0
- labctl/web/websocket.py +208 -0
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SDWire controller for switching SD cards between DUT and host.
|
|
3
|
+
|
|
4
|
+
Wraps the sdwire Python library to control SDWire devices.
|
|
5
|
+
|
|
6
|
+
Supported device types:
|
|
7
|
+
- sdwirec: SDWireC (Realtek-based, VID 0BDA:0316) — newer, faster
|
|
8
|
+
- sdwire: Legacy SDWire (FTDI-based, VID 04E8:6001)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import stat
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from labctl.extras import BrokenExtraError, MissingExtraError, is_missing
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SDWireSymlinkError(RuntimeError):
|
|
27
|
+
"""Raised when a read operation encounters a symlink."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, path: str, target: str):
|
|
30
|
+
super().__init__(f"Refusing to read symlink: {path}")
|
|
31
|
+
self.path = path
|
|
32
|
+
self.target = target
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SDWireController:
|
|
36
|
+
"""Controls an SDWire SD card multiplexer."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, serial_number: str, device_type: str = "sdwirec"):
|
|
39
|
+
self.serial_number = serial_number
|
|
40
|
+
self.device_type = device_type
|
|
41
|
+
|
|
42
|
+
def _get_device(self):
|
|
43
|
+
"""Find and return the sdwire device object by serial number."""
|
|
44
|
+
try:
|
|
45
|
+
from sdwire.backend.detect import (
|
|
46
|
+
get_sdwire_devices,
|
|
47
|
+
get_sdwirec_devices,
|
|
48
|
+
)
|
|
49
|
+
except ImportError as e:
|
|
50
|
+
if not is_missing(e, "sdwire"):
|
|
51
|
+
raise BrokenExtraError("sdwire", e) from e
|
|
52
|
+
raise MissingExtraError("sdwire") from e
|
|
53
|
+
|
|
54
|
+
all_devices = []
|
|
55
|
+
try:
|
|
56
|
+
all_devices.extend(get_sdwirec_devices())
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
try:
|
|
60
|
+
all_devices.extend(get_sdwire_devices())
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
for dev in all_devices:
|
|
65
|
+
if dev.serial_string == self.serial_number:
|
|
66
|
+
return dev
|
|
67
|
+
|
|
68
|
+
raise RuntimeError(
|
|
69
|
+
f"SDWire device with serial '{self.serial_number}' not found. "
|
|
70
|
+
"Is it connected?"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def ensure_present(self) -> None:
|
|
74
|
+
"""Raise RuntimeError unless the SDWire is connected."""
|
|
75
|
+
self._get_device()
|
|
76
|
+
|
|
77
|
+
def switch_to_dut(self) -> None:
|
|
78
|
+
"""Switch SD card to the DUT (target SBC boots from it)."""
|
|
79
|
+
logger.debug("Switching SDWire %s to DUT", self.serial_number)
|
|
80
|
+
device = self._get_device()
|
|
81
|
+
device.switch_dut()
|
|
82
|
+
logger.debug("SDWire %s switched to DUT", self.serial_number)
|
|
83
|
+
|
|
84
|
+
def switch_to_host(self) -> None:
|
|
85
|
+
"""Switch SD card to the host (dev machine can read/write it)."""
|
|
86
|
+
logger.debug("Switching SDWire %s to host", self.serial_number)
|
|
87
|
+
device = self._get_device()
|
|
88
|
+
device.switch_ts()
|
|
89
|
+
logger.debug("SDWire %s switched to host", self.serial_number)
|
|
90
|
+
|
|
91
|
+
def get_block_device(self, settle_time: float = 0) -> Optional[str]:
|
|
92
|
+
"""Get the block device path when SD card is connected to host.
|
|
93
|
+
|
|
94
|
+
Validates the device has actual media (non-zero size) to avoid
|
|
95
|
+
returning stale device nodes from previous enumerations.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
settle_time: Seconds to wait for device to settle (0 = no wait)
|
|
99
|
+
|
|
100
|
+
Returns /dev/sdX path or None if not available.
|
|
101
|
+
"""
|
|
102
|
+
import time
|
|
103
|
+
|
|
104
|
+
if settle_time > 0:
|
|
105
|
+
time.sleep(settle_time)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
device = self._get_device()
|
|
109
|
+
block_dev = getattr(device, "block_dev", None)
|
|
110
|
+
|
|
111
|
+
if block_dev and _block_device_has_media(block_dev):
|
|
112
|
+
return block_dev
|
|
113
|
+
|
|
114
|
+
if block_dev:
|
|
115
|
+
logger.debug("Block device %s has no media (stale node)", block_dev)
|
|
116
|
+
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
except (MissingExtraError, BrokenExtraError):
|
|
120
|
+
raise # not "no card": say why sdwire can't be used
|
|
121
|
+
except RuntimeError:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def _block_node(self) -> Optional[str]:
|
|
125
|
+
"""The reader's block device node, whether or not it has media."""
|
|
126
|
+
try:
|
|
127
|
+
node = getattr(self._get_device(), "block_dev", None)
|
|
128
|
+
except (MissingExtraError, BrokenExtraError):
|
|
129
|
+
raise
|
|
130
|
+
except RuntimeError:
|
|
131
|
+
return None
|
|
132
|
+
return node if node and os.path.exists(node) else None
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _probe_media(block_dev: str) -> Optional[bool]:
|
|
136
|
+
"""Force the kernel to re-check the reader for media.
|
|
137
|
+
|
|
138
|
+
``/sys/block/<dev>/size`` lags a mux switch in both directions until
|
|
139
|
+
the kernel next checks the reader (it polls removable disks only if
|
|
140
|
+
``block.events_dfl_poll_msecs`` is set, ~2 s on many distros, and
|
|
141
|
+
otherwise only on open). Opening the device forces the check, so a
|
|
142
|
+
one-sector read to /dev/null (``sudo dd`` is already permitted for
|
|
143
|
+
flashing) answers: True = readable media, False = "No medium found",
|
|
144
|
+
None = can't tell (no sudo rights, timeout, other errors). LC_ALL=C
|
|
145
|
+
keeps dd's message in English so the match is locale-independent.
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
probe = subprocess.run(
|
|
149
|
+
[
|
|
150
|
+
"sudo",
|
|
151
|
+
"-n",
|
|
152
|
+
"dd",
|
|
153
|
+
f"if={block_dev}",
|
|
154
|
+
"of=/dev/null",
|
|
155
|
+
"bs=512",
|
|
156
|
+
"count=1",
|
|
157
|
+
"status=none",
|
|
158
|
+
],
|
|
159
|
+
capture_output=True,
|
|
160
|
+
text=True,
|
|
161
|
+
timeout=10,
|
|
162
|
+
env={**os.environ, "LC_ALL": "C"},
|
|
163
|
+
)
|
|
164
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
165
|
+
logger.debug("Media probe of %s unavailable: %s", block_dev, e)
|
|
166
|
+
return None
|
|
167
|
+
if probe.returncode == 0:
|
|
168
|
+
return True
|
|
169
|
+
if "No medium found" in probe.stderr:
|
|
170
|
+
return False
|
|
171
|
+
logger.debug("Media probe of %s failed: %s", block_dev, probe.stderr.strip())
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
def card_on_host(self) -> bool:
|
|
175
|
+
"""True if the SD card is currently switched to the host.
|
|
176
|
+
|
|
177
|
+
Probes the reader (``_probe_media``) whenever its device node
|
|
178
|
+
exists, even if the cached size says 0, so neither a just-switched-
|
|
179
|
+
to-host card nor a just-switched-to-DUT card is misjudged. If the
|
|
180
|
+
probe can't tell, fall back to the cached size.
|
|
181
|
+
"""
|
|
182
|
+
node = self._block_node()
|
|
183
|
+
if not node:
|
|
184
|
+
return False
|
|
185
|
+
probed = self._probe_media(node)
|
|
186
|
+
if probed is not None:
|
|
187
|
+
return probed
|
|
188
|
+
return _block_device_has_media(node)
|
|
189
|
+
|
|
190
|
+
def wait_for_host(
|
|
191
|
+
self, timeout: float = 10.0, interval: float = 0.5
|
|
192
|
+
) -> Optional[str]:
|
|
193
|
+
"""After ``switch_to_host``, wait until the card is readable.
|
|
194
|
+
|
|
195
|
+
Returns the block device, or None if it didn't appear in time
|
|
196
|
+
(USB re-enumeration and media detection take a moment).
|
|
197
|
+
"""
|
|
198
|
+
import time
|
|
199
|
+
|
|
200
|
+
deadline = time.monotonic() + timeout
|
|
201
|
+
while True:
|
|
202
|
+
if self.card_on_host():
|
|
203
|
+
return self.get_block_device() or self._block_node()
|
|
204
|
+
if time.monotonic() >= deadline:
|
|
205
|
+
return None
|
|
206
|
+
time.sleep(interval)
|
|
207
|
+
|
|
208
|
+
def flash_image(
|
|
209
|
+
self,
|
|
210
|
+
image_path: str,
|
|
211
|
+
block_size: str = "4M",
|
|
212
|
+
timeout: int = 1800,
|
|
213
|
+
format_name: Optional[str] = None,
|
|
214
|
+
image_fd: Optional[int] = None,
|
|
215
|
+
) -> dict:
|
|
216
|
+
"""Write an image to the SD card.
|
|
217
|
+
|
|
218
|
+
Supports raw .img, .img.xz, and .img.gz files. Assumes the SD
|
|
219
|
+
card is already switched to host mode.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
image_path: Path to the image file (what is actually read)
|
|
223
|
+
block_size: Block size for dd (default: 4M)
|
|
224
|
+
timeout: Max seconds for the write (default: 1800 / 30 min)
|
|
225
|
+
format_name: Name whose extension selects the format, if it
|
|
226
|
+
differs from ``image_path``. Callers that resolved a symlink
|
|
227
|
+
(e.g. ``latest.img.xz`` -> ``build-4711``) pass the name the
|
|
228
|
+
user gave, so the format still follows it.
|
|
229
|
+
image_fd: An already-open, checked descriptor for the image. When
|
|
230
|
+
given, the data is read from it (fed to xz/gzip/dd on stdin)
|
|
231
|
+
and ``image_path`` is only used for messages, so nothing
|
|
232
|
+
re-opens the path after the caller's check, and root (dd)
|
|
233
|
+
never opens a host path at all.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Dict with bytes_written and elapsed_seconds.
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
RuntimeError: On validation failure or write error.
|
|
240
|
+
"""
|
|
241
|
+
import time as time_mod
|
|
242
|
+
|
|
243
|
+
block_dev = self.get_block_device()
|
|
244
|
+
if not block_dev:
|
|
245
|
+
raise RuntimeError(
|
|
246
|
+
"Cannot determine block device. "
|
|
247
|
+
"Is the SD card switched to host mode?"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Safety checks
|
|
251
|
+
_validate_block_device(block_dev)
|
|
252
|
+
_validate_image_file(image_path, format_name, image_fd=image_fd)
|
|
253
|
+
fmt = format_name or image_path
|
|
254
|
+
if image_fd is not None:
|
|
255
|
+
os.lseek(image_fd, 0, os.SEEK_SET)
|
|
256
|
+
# Read from the checked descriptor when given, else from the path.
|
|
257
|
+
source = ["-"] if image_fd is not None else [image_path]
|
|
258
|
+
|
|
259
|
+
logger.info(
|
|
260
|
+
"Flashing %s to %s via SDWire %s",
|
|
261
|
+
image_path,
|
|
262
|
+
block_dev,
|
|
263
|
+
self.serial_number,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
start = time_mod.monotonic()
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
if fmt.endswith(".xz"):
|
|
270
|
+
# Pipe: xz -dc image | sudo dd of=dev bs=4M oflag=sync
|
|
271
|
+
decompress = subprocess.Popen(
|
|
272
|
+
["xz", "-dc", *source],
|
|
273
|
+
stdin=image_fd,
|
|
274
|
+
stdout=subprocess.PIPE,
|
|
275
|
+
)
|
|
276
|
+
dd = subprocess.Popen(
|
|
277
|
+
[
|
|
278
|
+
"sudo",
|
|
279
|
+
"dd",
|
|
280
|
+
f"of={block_dev}",
|
|
281
|
+
f"bs={block_size}",
|
|
282
|
+
"oflag=sync",
|
|
283
|
+
"status=progress",
|
|
284
|
+
],
|
|
285
|
+
stdin=decompress.stdout,
|
|
286
|
+
stderr=subprocess.PIPE,
|
|
287
|
+
)
|
|
288
|
+
decompress.stdout.close()
|
|
289
|
+
_, dd_stderr = dd.communicate(timeout=timeout)
|
|
290
|
+
if decompress.wait() != 0:
|
|
291
|
+
raise RuntimeError("xz decompression failed")
|
|
292
|
+
if dd.returncode != 0:
|
|
293
|
+
raise RuntimeError(
|
|
294
|
+
f"dd failed: {dd_stderr.decode('utf-8', errors='replace')}"
|
|
295
|
+
)
|
|
296
|
+
elif fmt.endswith(".gz"):
|
|
297
|
+
decompress = subprocess.Popen(
|
|
298
|
+
["gzip", "-dc", *source],
|
|
299
|
+
stdin=image_fd,
|
|
300
|
+
stdout=subprocess.PIPE,
|
|
301
|
+
)
|
|
302
|
+
dd = subprocess.Popen(
|
|
303
|
+
[
|
|
304
|
+
"sudo",
|
|
305
|
+
"dd",
|
|
306
|
+
f"of={block_dev}",
|
|
307
|
+
f"bs={block_size}",
|
|
308
|
+
"oflag=sync",
|
|
309
|
+
"status=progress",
|
|
310
|
+
],
|
|
311
|
+
stdin=decompress.stdout,
|
|
312
|
+
stderr=subprocess.PIPE,
|
|
313
|
+
)
|
|
314
|
+
decompress.stdout.close()
|
|
315
|
+
_, dd_stderr = dd.communicate(timeout=timeout)
|
|
316
|
+
if decompress.wait() != 0:
|
|
317
|
+
raise RuntimeError("gzip decompression failed")
|
|
318
|
+
if dd.returncode != 0:
|
|
319
|
+
raise RuntimeError(
|
|
320
|
+
f"dd failed: {dd_stderr.decode('utf-8', errors='replace')}"
|
|
321
|
+
)
|
|
322
|
+
else:
|
|
323
|
+
# With a descriptor, dd reads stdin: root never opens the
|
|
324
|
+
# host path, so a swapped symlink can't redirect the read.
|
|
325
|
+
dd_input = [] if image_fd is not None else [f"if={image_path}"]
|
|
326
|
+
subprocess.run(
|
|
327
|
+
[
|
|
328
|
+
"sudo",
|
|
329
|
+
"dd",
|
|
330
|
+
*dd_input,
|
|
331
|
+
f"of={block_dev}",
|
|
332
|
+
f"bs={block_size}",
|
|
333
|
+
"status=progress",
|
|
334
|
+
"conv=fsync",
|
|
335
|
+
],
|
|
336
|
+
stdin=image_fd,
|
|
337
|
+
check=True,
|
|
338
|
+
timeout=timeout,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
subprocess.run(["sudo", "sync"], check=True)
|
|
342
|
+
|
|
343
|
+
elapsed = time_mod.monotonic() - start
|
|
344
|
+
image_size = (
|
|
345
|
+
os.fstat(image_fd).st_size
|
|
346
|
+
if image_fd is not None
|
|
347
|
+
else os.path.getsize(image_path)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
"bytes_written": image_size,
|
|
352
|
+
"elapsed_seconds": round(elapsed, 1),
|
|
353
|
+
"block_device": block_dev,
|
|
354
|
+
}
|
|
355
|
+
except subprocess.TimeoutExpired:
|
|
356
|
+
raise RuntimeError(f"Flash timed out after {timeout}s")
|
|
357
|
+
except subprocess.CalledProcessError as e:
|
|
358
|
+
raise RuntimeError(f"Flash failed: {e}") from e
|
|
359
|
+
|
|
360
|
+
def update_files(
|
|
361
|
+
self,
|
|
362
|
+
partition: int,
|
|
363
|
+
file_pairs: list[tuple[str | int, str]],
|
|
364
|
+
renames: list[tuple[str, str]] | None = None,
|
|
365
|
+
deletes: list[str] | None = None,
|
|
366
|
+
source_names: list[str] | None = None,
|
|
367
|
+
) -> dict[str, list[str]]:
|
|
368
|
+
"""Copy, rename, and/or delete files on a partition on the SD card.
|
|
369
|
+
|
|
370
|
+
Mounts the partition, performs operations in order (copies, renames,
|
|
371
|
+
deletes), unmounts. Assumes SD card is already switched to host mode.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
partition: Partition number (e.g., 1 for /dev/sdb1)
|
|
375
|
+
file_pairs: List of (source, dest_path_relative_to_partition_root);
|
|
376
|
+
source is a host path or an already-open, checked file
|
|
377
|
+
descriptor (read from directly, never re-opened by path)
|
|
378
|
+
renames: List of (old_name, new_name) relative to partition root
|
|
379
|
+
deletes: List of filenames relative to partition root
|
|
380
|
+
source_names: Optional file names for the copies, aligned with
|
|
381
|
+
``file_pairs``. When a destination is an existing
|
|
382
|
+
directory the copy is named after this instead of the
|
|
383
|
+
source path, so a caller that resolved a symlink (e.g.
|
|
384
|
+
``Image`` -> ``Image-6.1.55``) still gets ``Image``.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
Dict with keys "copied", "renamed", "deleted", each a list of strings.
|
|
388
|
+
|
|
389
|
+
Raises:
|
|
390
|
+
RuntimeError: If block device not found, mount fails, or any op fails.
|
|
391
|
+
"""
|
|
392
|
+
block_dev = self.get_block_device()
|
|
393
|
+
if not block_dev:
|
|
394
|
+
raise RuntimeError(
|
|
395
|
+
"Cannot determine block device. "
|
|
396
|
+
"Is the SD card switched to host mode?"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
result = {"copied": [], "renamed": [], "deleted": []}
|
|
400
|
+
with self.host_mount(partition, mode="rw", owner_mount=True) as mount:
|
|
401
|
+
# 1. Copies
|
|
402
|
+
for i, (src, dest_relative) in enumerate(file_pairs):
|
|
403
|
+
dest = mount.resolve_path(dest_relative)
|
|
404
|
+
if source_names and os.path.isdir(dest):
|
|
405
|
+
dest = mount.resolve_path(
|
|
406
|
+
os.path.join(dest_relative, source_names[i])
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
dest_dir = os.path.dirname(dest)
|
|
410
|
+
if dest_dir and not os.path.exists(dest_dir):
|
|
411
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
412
|
+
|
|
413
|
+
logger.info("Copying %s -> %s", src, dest_relative)
|
|
414
|
+
if isinstance(src, int):
|
|
415
|
+
_copy_from_fd(src, dest)
|
|
416
|
+
else:
|
|
417
|
+
shutil.copy2(src, dest)
|
|
418
|
+
result["copied"].append(dest_relative)
|
|
419
|
+
|
|
420
|
+
# 2. Renames
|
|
421
|
+
for old_name, new_name in renames or []:
|
|
422
|
+
old_path = mount.resolve_path(old_name)
|
|
423
|
+
new_path = mount.resolve_path(new_name)
|
|
424
|
+
|
|
425
|
+
if not os.path.exists(old_path):
|
|
426
|
+
raise RuntimeError(
|
|
427
|
+
f"Rename failed: '{old_name}' not found on partition"
|
|
428
|
+
)
|
|
429
|
+
if os.path.exists(new_path):
|
|
430
|
+
raise RuntimeError(
|
|
431
|
+
f"Rename failed: '{new_name}' already exists on partition"
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
new_dir = os.path.dirname(new_path)
|
|
435
|
+
if new_dir and not os.path.exists(new_dir):
|
|
436
|
+
os.makedirs(new_dir, exist_ok=True)
|
|
437
|
+
|
|
438
|
+
logger.info("Renaming %s -> %s", old_name, new_name)
|
|
439
|
+
os.rename(old_path, new_path)
|
|
440
|
+
result["renamed"].append(f"{old_name} -> {new_name}")
|
|
441
|
+
|
|
442
|
+
# 3. Deletes
|
|
443
|
+
for filename in deletes or []:
|
|
444
|
+
file_path = mount.resolve_path(filename)
|
|
445
|
+
|
|
446
|
+
if not os.path.exists(file_path):
|
|
447
|
+
raise RuntimeError(
|
|
448
|
+
f"Delete failed: '{filename}' not found on partition"
|
|
449
|
+
)
|
|
450
|
+
if os.path.isdir(file_path):
|
|
451
|
+
raise RuntimeError(
|
|
452
|
+
f"Delete failed: '{filename}' is a directory, not a file"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
logger.info("Deleting %s", filename)
|
|
456
|
+
os.remove(file_path)
|
|
457
|
+
result["deleted"].append(filename)
|
|
458
|
+
|
|
459
|
+
subprocess.run(["sudo", "sync"], check=True)
|
|
460
|
+
return result
|
|
461
|
+
|
|
462
|
+
@contextmanager
|
|
463
|
+
def host_mount(self, partition: int, mode: str = "ro", owner_mount: bool = False):
|
|
464
|
+
"""Mount a partition on the SD card and yield a safe mount helper."""
|
|
465
|
+
if mode not in {"ro", "rw"}:
|
|
466
|
+
raise ValueError("mode must be 'ro' or 'rw'")
|
|
467
|
+
|
|
468
|
+
block_dev = self.get_block_device()
|
|
469
|
+
if not block_dev:
|
|
470
|
+
raise RuntimeError(
|
|
471
|
+
"Cannot determine block device. "
|
|
472
|
+
"Is the SD card switched to host mode?"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
part_dev = f"{block_dev}{partition}"
|
|
476
|
+
mount_point = tempfile.mkdtemp(prefix="labctl-sdwire-")
|
|
477
|
+
base = ["nosuid", "nodev", "noexec", "noatime"]
|
|
478
|
+
if mode == "ro":
|
|
479
|
+
attempts = [["ro", *extra, *base] for extra in _ro_no_replay(part_dev)]
|
|
480
|
+
else:
|
|
481
|
+
attempts = [[f"uid={os.getuid()},gid={os.getgid()}", *base]]
|
|
482
|
+
|
|
483
|
+
logger.info("Mounting %s at %s (%s)", part_dev, mount_point, mode)
|
|
484
|
+
|
|
485
|
+
last_err: Optional[subprocess.CalledProcessError] = None
|
|
486
|
+
for options in attempts:
|
|
487
|
+
try:
|
|
488
|
+
subprocess.run(
|
|
489
|
+
[
|
|
490
|
+
"sudo",
|
|
491
|
+
"mount",
|
|
492
|
+
"-o",
|
|
493
|
+
",".join(options),
|
|
494
|
+
part_dev,
|
|
495
|
+
mount_point,
|
|
496
|
+
],
|
|
497
|
+
check=True,
|
|
498
|
+
capture_output=True,
|
|
499
|
+
text=True,
|
|
500
|
+
)
|
|
501
|
+
last_err = None
|
|
502
|
+
break
|
|
503
|
+
except subprocess.CalledProcessError as e:
|
|
504
|
+
last_err = e
|
|
505
|
+
if last_err is not None:
|
|
506
|
+
os.rmdir(mount_point)
|
|
507
|
+
stderr = (last_err.stderr or "").strip()
|
|
508
|
+
raise RuntimeError(f"Failed to mount {part_dev}: {stderr}") from last_err
|
|
509
|
+
|
|
510
|
+
mount = _MountedPartition(mount_point, owner_mount=owner_mount)
|
|
511
|
+
try:
|
|
512
|
+
yield mount
|
|
513
|
+
finally:
|
|
514
|
+
logger.info("Unmounting %s", mount_point)
|
|
515
|
+
subprocess.run(
|
|
516
|
+
["sudo", "umount", mount_point],
|
|
517
|
+
capture_output=True,
|
|
518
|
+
)
|
|
519
|
+
try:
|
|
520
|
+
os.rmdir(mount_point)
|
|
521
|
+
except OSError:
|
|
522
|
+
pass
|
|
523
|
+
|
|
524
|
+
def list_files(
|
|
525
|
+
self,
|
|
526
|
+
partition: int,
|
|
527
|
+
path: str = "/",
|
|
528
|
+
recursive: bool = False,
|
|
529
|
+
max_entries: int = 1000,
|
|
530
|
+
) -> dict:
|
|
531
|
+
"""List entries on a partition with bounded output."""
|
|
532
|
+
if max_entries <= 0:
|
|
533
|
+
raise RuntimeError("max_entries must be greater than 0")
|
|
534
|
+
|
|
535
|
+
with self.host_mount(partition, mode="ro") as mount:
|
|
536
|
+
target = mount.resolve_path(path)
|
|
537
|
+
if not os.path.exists(target):
|
|
538
|
+
raise FileNotFoundError(path)
|
|
539
|
+
if not os.path.isdir(target):
|
|
540
|
+
raise NotADirectoryError(path)
|
|
541
|
+
|
|
542
|
+
entries: list[dict] = []
|
|
543
|
+
truncated = False
|
|
544
|
+
pending = [(target, path.rstrip("/") or "/")]
|
|
545
|
+
|
|
546
|
+
while pending:
|
|
547
|
+
current_fs_path, current_display_path = pending.pop(0)
|
|
548
|
+
try:
|
|
549
|
+
with os.scandir(current_fs_path) as it:
|
|
550
|
+
for entry in it:
|
|
551
|
+
if len(entries) >= max_entries:
|
|
552
|
+
truncated = True
|
|
553
|
+
break
|
|
554
|
+
|
|
555
|
+
entry_path = (
|
|
556
|
+
f"{current_display_path.rstrip('/')}/{entry.name}"
|
|
557
|
+
if current_display_path != "/"
|
|
558
|
+
else f"/{entry.name}"
|
|
559
|
+
)
|
|
560
|
+
entries.append(
|
|
561
|
+
_serialize_dir_entry(
|
|
562
|
+
entry,
|
|
563
|
+
path=entry_path,
|
|
564
|
+
root=target,
|
|
565
|
+
)
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
if recursive and entry.is_dir(follow_symlinks=False):
|
|
569
|
+
pending.append((entry.path, entry_path))
|
|
570
|
+
except PermissionError as e:
|
|
571
|
+
raise PermissionError(path) from e
|
|
572
|
+
|
|
573
|
+
if truncated:
|
|
574
|
+
break
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
"entries": entries,
|
|
578
|
+
"truncated": truncated,
|
|
579
|
+
"_truncated": truncated,
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
def read_file(
|
|
583
|
+
self,
|
|
584
|
+
partition: int,
|
|
585
|
+
path: str,
|
|
586
|
+
max_bytes: int = 1024 * 1024,
|
|
587
|
+
encoding: str = "text",
|
|
588
|
+
) -> dict:
|
|
589
|
+
"""Read a file from a partition with size and encoding guards."""
|
|
590
|
+
if max_bytes <= 0:
|
|
591
|
+
raise RuntimeError("max_bytes must be greater than 0")
|
|
592
|
+
if encoding not in {"text", "base64", "hex"}:
|
|
593
|
+
raise RuntimeError("encoding must be one of: text, base64, hex")
|
|
594
|
+
|
|
595
|
+
with self.host_mount(partition, mode="ro") as mount:
|
|
596
|
+
target = mount.resolve_path(path)
|
|
597
|
+
if not os.path.lexists(target):
|
|
598
|
+
raise FileNotFoundError(path)
|
|
599
|
+
if os.path.islink(target):
|
|
600
|
+
raise SDWireSymlinkError(path, os.readlink(target))
|
|
601
|
+
if not os.path.isfile(target):
|
|
602
|
+
raise IsADirectoryError(path)
|
|
603
|
+
|
|
604
|
+
stat_result = os.stat(target)
|
|
605
|
+
if stat_result.st_size > max_bytes:
|
|
606
|
+
raise RuntimeError(
|
|
607
|
+
f"File exceeds max_bytes ({stat_result.st_size} > {max_bytes})"
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
try:
|
|
611
|
+
with open(target, "rb") as f:
|
|
612
|
+
data = f.read(max_bytes + 1)
|
|
613
|
+
except PermissionError as e:
|
|
614
|
+
raise PermissionError(path) from e
|
|
615
|
+
|
|
616
|
+
if len(data) > max_bytes:
|
|
617
|
+
raise RuntimeError(
|
|
618
|
+
f"File exceeds max_bytes ({len(data)} > {max_bytes})"
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
metadata = _serialize_stat(stat_result, owner_mount=False)
|
|
622
|
+
|
|
623
|
+
if encoding == "text":
|
|
624
|
+
try:
|
|
625
|
+
content = data.decode("utf-8")
|
|
626
|
+
except UnicodeDecodeError as e:
|
|
627
|
+
raise ValueError("binary_content") from e
|
|
628
|
+
elif encoding == "base64":
|
|
629
|
+
import base64
|
|
630
|
+
|
|
631
|
+
content = base64.b64encode(data).decode("ascii")
|
|
632
|
+
else:
|
|
633
|
+
content = data.hex()
|
|
634
|
+
|
|
635
|
+
return {
|
|
636
|
+
"content": content,
|
|
637
|
+
"encoding": encoding,
|
|
638
|
+
**metadata,
|
|
639
|
+
"truncated": False,
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
def get_disk_info(self) -> dict:
|
|
643
|
+
"""Return partition and filesystem metadata for the current SD card."""
|
|
644
|
+
block_dev = self.get_block_device()
|
|
645
|
+
if not block_dev:
|
|
646
|
+
raise RuntimeError(
|
|
647
|
+
"Cannot determine block device. "
|
|
648
|
+
"Is the SD card switched to host mode?"
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
try:
|
|
652
|
+
parted = subprocess.run(
|
|
653
|
+
[
|
|
654
|
+
"sudo",
|
|
655
|
+
"parted",
|
|
656
|
+
"-s",
|
|
657
|
+
"-m",
|
|
658
|
+
block_dev,
|
|
659
|
+
"unit",
|
|
660
|
+
"MiB",
|
|
661
|
+
"print",
|
|
662
|
+
"free",
|
|
663
|
+
],
|
|
664
|
+
check=True,
|
|
665
|
+
capture_output=True,
|
|
666
|
+
text=True,
|
|
667
|
+
)
|
|
668
|
+
except OSError as e:
|
|
669
|
+
raise RuntimeError(f"Failed to read partition table: {e}") from e
|
|
670
|
+
except subprocess.CalledProcessError as e:
|
|
671
|
+
stderr = (e.stderr or "").strip()
|
|
672
|
+
raise RuntimeError(f"Failed to read partition table: {stderr}") from e
|
|
673
|
+
|
|
674
|
+
return _parse_parted_output(block_dev, parted.stdout)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
class _MountedPartition:
|
|
678
|
+
def __init__(self, mount_point: str, owner_mount: bool = False):
|
|
679
|
+
self.mount_point = mount_point
|
|
680
|
+
self.owner_mount = owner_mount
|
|
681
|
+
self._real_mount = os.path.realpath(mount_point)
|
|
682
|
+
|
|
683
|
+
def resolve_path(self, path: str) -> str:
|
|
684
|
+
relative = path.lstrip("/")
|
|
685
|
+
full = os.path.normpath(os.path.join(self.mount_point, relative))
|
|
686
|
+
if os.path.commonpath([self.mount_point, full]) != self.mount_point:
|
|
687
|
+
raise RuntimeError(f"Path traversal rejected: '{path}' escapes partition")
|
|
688
|
+
|
|
689
|
+
parent = full if full == self.mount_point else os.path.dirname(full)
|
|
690
|
+
real_parent = os.path.realpath(parent)
|
|
691
|
+
if (
|
|
692
|
+
not real_parent.startswith(self._real_mount + "/")
|
|
693
|
+
and real_parent != self._real_mount
|
|
694
|
+
):
|
|
695
|
+
raise RuntimeError(
|
|
696
|
+
f"Path traversal rejected: '{path}' escapes partition via symlink"
|
|
697
|
+
)
|
|
698
|
+
if os.path.lexists(full):
|
|
699
|
+
real_full = os.path.realpath(full)
|
|
700
|
+
if (
|
|
701
|
+
not real_full.startswith(self._real_mount + "/")
|
|
702
|
+
and real_full != self._real_mount
|
|
703
|
+
):
|
|
704
|
+
raise RuntimeError(
|
|
705
|
+
f"Path traversal rejected: '{path}' target escapes partition"
|
|
706
|
+
)
|
|
707
|
+
return full
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _serialize_mtime(timestamp: float) -> str:
|
|
711
|
+
# timezone.utc rather than datetime.UTC: the latter is Python 3.11+.
|
|
712
|
+
utc = datetime.fromtimestamp(timestamp, timezone.utc)
|
|
713
|
+
return utc.isoformat().replace("+00:00", "Z")
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _serialize_stat(stat_result, owner_mount: bool) -> dict:
|
|
717
|
+
mode = stat_result.st_mode & 0o7777
|
|
718
|
+
if owner_mount:
|
|
719
|
+
mode &= ~0o200
|
|
720
|
+
return {
|
|
721
|
+
"size": stat_result.st_size,
|
|
722
|
+
"mtime": _serialize_mtime(stat_result.st_mtime),
|
|
723
|
+
"mode": f"{mode:04o}",
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _serialize_dir_entry(entry, path: str, root: str) -> dict:
|
|
728
|
+
stat_result = entry.stat(follow_symlinks=False)
|
|
729
|
+
if entry.is_file(follow_symlinks=False):
|
|
730
|
+
entry_type = "file"
|
|
731
|
+
elif entry.is_dir(follow_symlinks=False):
|
|
732
|
+
entry_type = "dir"
|
|
733
|
+
elif entry.is_symlink():
|
|
734
|
+
entry_type = "symlink"
|
|
735
|
+
else:
|
|
736
|
+
entry_type = "other"
|
|
737
|
+
|
|
738
|
+
rel_path = os.path.relpath(entry.path, root)
|
|
739
|
+
return {
|
|
740
|
+
"path": path,
|
|
741
|
+
"name": entry.name,
|
|
742
|
+
"relative_path": "." if rel_path == "." else rel_path,
|
|
743
|
+
"type": entry_type,
|
|
744
|
+
**_serialize_stat(stat_result, owner_mount=False),
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _parse_parted_output(block_dev: str, output: str) -> dict:
|
|
749
|
+
try:
|
|
750
|
+
blkid = subprocess.run(
|
|
751
|
+
["sudo", "blkid", "-o", "export", block_dev],
|
|
752
|
+
check=False,
|
|
753
|
+
capture_output=True,
|
|
754
|
+
text=True,
|
|
755
|
+
)
|
|
756
|
+
base_device_meta = _parse_blkid_export(blkid.stdout)
|
|
757
|
+
except Exception:
|
|
758
|
+
base_device_meta = {}
|
|
759
|
+
|
|
760
|
+
partitions = []
|
|
761
|
+
free_regions = []
|
|
762
|
+
device_total_bytes = None
|
|
763
|
+
disklabel_type = None
|
|
764
|
+
|
|
765
|
+
for raw_line in output.splitlines():
|
|
766
|
+
line = raw_line.strip()
|
|
767
|
+
if not line or line.startswith("BYT;"):
|
|
768
|
+
continue
|
|
769
|
+
parts = [segment.strip('"') for segment in line.split(":")]
|
|
770
|
+
if line.startswith(block_dev + ":"):
|
|
771
|
+
if len(parts) >= 6:
|
|
772
|
+
device_total_bytes = _mib_to_bytes(parts[1])
|
|
773
|
+
disklabel_type = parts[5] or None
|
|
774
|
+
continue
|
|
775
|
+
if parts[0] in {"Number", "Model", "Disk", "Sector", "Partition", ""}:
|
|
776
|
+
continue
|
|
777
|
+
if len(parts) < 5:
|
|
778
|
+
continue
|
|
779
|
+
|
|
780
|
+
start_mib = _parse_mib(parts[1])
|
|
781
|
+
end_mib = _parse_mib(parts[2])
|
|
782
|
+
size_mib = _parse_mib(parts[3])
|
|
783
|
+
if parts[4].rstrip(";") == "free":
|
|
784
|
+
free_regions.append(
|
|
785
|
+
{
|
|
786
|
+
"start_mib": start_mib,
|
|
787
|
+
"end_mib": end_mib,
|
|
788
|
+
"size_mib": size_mib,
|
|
789
|
+
}
|
|
790
|
+
)
|
|
791
|
+
continue
|
|
792
|
+
|
|
793
|
+
part_num = int(parts[0])
|
|
794
|
+
part_dev = f"{block_dev}{part_num}"
|
|
795
|
+
try:
|
|
796
|
+
blkid_part = subprocess.run(
|
|
797
|
+
["sudo", "blkid", "-o", "export", part_dev],
|
|
798
|
+
check=False,
|
|
799
|
+
capture_output=True,
|
|
800
|
+
text=True,
|
|
801
|
+
)
|
|
802
|
+
part_meta = _parse_blkid_export(blkid_part.stdout)
|
|
803
|
+
except Exception:
|
|
804
|
+
part_meta = {}
|
|
805
|
+
|
|
806
|
+
partitions.append(
|
|
807
|
+
{
|
|
808
|
+
"num": part_num,
|
|
809
|
+
"start_mib": start_mib,
|
|
810
|
+
"end_mib": end_mib,
|
|
811
|
+
"size_mib": size_mib,
|
|
812
|
+
"type": parts[4] or None,
|
|
813
|
+
"label": parts[5] or part_meta.get("LABEL"),
|
|
814
|
+
"flags": (
|
|
815
|
+
[flag for flag in parts[6].rstrip(";").split(",") if flag]
|
|
816
|
+
if len(parts) > 6 and parts[6].rstrip(";")
|
|
817
|
+
else []
|
|
818
|
+
),
|
|
819
|
+
"partuuid": part_meta.get("PARTUUID"),
|
|
820
|
+
"filesystem_uuid": part_meta.get("UUID"),
|
|
821
|
+
"mount_status": "mounted" if _is_mounted(part_dev) else "clean",
|
|
822
|
+
}
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
if device_total_bytes is None:
|
|
826
|
+
raise RuntimeError("Unable to parse partition table")
|
|
827
|
+
|
|
828
|
+
return {
|
|
829
|
+
"device_total_bytes": device_total_bytes,
|
|
830
|
+
"disklabel_type": disklabel_type or base_device_meta.get("PTTYPE"),
|
|
831
|
+
"partitions": partitions,
|
|
832
|
+
"free_space_regions": free_regions,
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _parse_blkid_export(output: str) -> dict[str, str]:
|
|
837
|
+
result = {}
|
|
838
|
+
for line in output.splitlines():
|
|
839
|
+
if "=" not in line:
|
|
840
|
+
continue
|
|
841
|
+
key, value = line.split("=", 1)
|
|
842
|
+
result[key.strip()] = value.strip()
|
|
843
|
+
return result
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _parse_mib(value: str) -> float:
|
|
847
|
+
return float(value.removesuffix("MiB"))
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _mib_to_bytes(value: str) -> int:
|
|
851
|
+
return int(round(_parse_mib(value) * 1024 * 1024))
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _is_mounted(device: str) -> bool:
|
|
855
|
+
try:
|
|
856
|
+
with open("/proc/mounts") as f:
|
|
857
|
+
for line in f:
|
|
858
|
+
mount_dev = line.split()[0] if line.strip() else ""
|
|
859
|
+
if mount_dev == device:
|
|
860
|
+
return True
|
|
861
|
+
except OSError:
|
|
862
|
+
return False
|
|
863
|
+
return False
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _validate_block_device(block_dev: str) -> None:
|
|
867
|
+
"""Validate a block device is safe to write to.
|
|
868
|
+
|
|
869
|
+
Checks:
|
|
870
|
+
- Device has media (non-zero size)
|
|
871
|
+
- Device is not too large (max 256 GB — not a system disk)
|
|
872
|
+
- No partitions are currently mounted
|
|
873
|
+
|
|
874
|
+
Raises:
|
|
875
|
+
RuntimeError: If any check fails.
|
|
876
|
+
"""
|
|
877
|
+
dev_name = os.path.basename(block_dev)
|
|
878
|
+
size_path = f"/sys/block/{dev_name}/size"
|
|
879
|
+
|
|
880
|
+
try:
|
|
881
|
+
with open(size_path) as f:
|
|
882
|
+
sectors = int(f.read().strip())
|
|
883
|
+
except (OSError, ValueError):
|
|
884
|
+
raise RuntimeError(f"Cannot read size of {block_dev}")
|
|
885
|
+
|
|
886
|
+
if sectors == 0:
|
|
887
|
+
raise RuntimeError(f"Block device {block_dev} reports 0 size (no media?)")
|
|
888
|
+
|
|
889
|
+
# 512 bytes per sector; 256 GB max
|
|
890
|
+
size_bytes = sectors * 512
|
|
891
|
+
max_bytes = 256 * 1024 * 1024 * 1024
|
|
892
|
+
if size_bytes > max_bytes:
|
|
893
|
+
size_gb = size_bytes / (1024**3)
|
|
894
|
+
raise RuntimeError(
|
|
895
|
+
f"Block device {block_dev} is {size_gb:.0f} GB — too large for SD card "
|
|
896
|
+
f"(max 256 GB). Refusing to write for safety."
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
# Check no partitions are mounted
|
|
900
|
+
try:
|
|
901
|
+
with open("/proc/mounts") as f:
|
|
902
|
+
mounts = f.read()
|
|
903
|
+
if block_dev in mounts or f"{block_dev}p" in mounts:
|
|
904
|
+
raise RuntimeError(
|
|
905
|
+
f"Block device {block_dev} has mounted partitions. "
|
|
906
|
+
f"Unmount before flashing."
|
|
907
|
+
)
|
|
908
|
+
# Also check numbered partitions (e.g., /dev/sdb1)
|
|
909
|
+
for line in mounts.splitlines():
|
|
910
|
+
mount_dev = line.split()[0] if line.strip() else ""
|
|
911
|
+
if mount_dev.startswith(block_dev):
|
|
912
|
+
raise RuntimeError(
|
|
913
|
+
f"Partition {mount_dev} is mounted. Unmount before flashing."
|
|
914
|
+
)
|
|
915
|
+
except OSError:
|
|
916
|
+
pass # /proc/mounts not available — skip check
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _copy_from_fd(src_fd: int, dest: str) -> None:
|
|
920
|
+
"""Copy an open file to ``dest`` like ``shutil.copy2``, by descriptor.
|
|
921
|
+
|
|
922
|
+
Reads from the start of ``src_fd`` (the caller's checked handle), then
|
|
923
|
+
copies mode and timestamps best-effort (FAT filesystems may refuse).
|
|
924
|
+
"""
|
|
925
|
+
os.lseek(src_fd, 0, os.SEEK_SET)
|
|
926
|
+
with open(dest, "wb") as out:
|
|
927
|
+
while chunk := os.read(src_fd, 1024 * 1024):
|
|
928
|
+
out.write(chunk)
|
|
929
|
+
st = os.fstat(src_fd)
|
|
930
|
+
try:
|
|
931
|
+
os.chmod(dest, stat.S_IMODE(st.st_mode))
|
|
932
|
+
os.utime(dest, ns=(st.st_atime_ns, st.st_mtime_ns))
|
|
933
|
+
except OSError as e:
|
|
934
|
+
logger.debug("Could not copy metadata to %s: %s", dest, e)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _validate_image_file(
|
|
938
|
+
image_path: str,
|
|
939
|
+
format_name: Optional[str] = None,
|
|
940
|
+
image_fd: Optional[int] = None,
|
|
941
|
+
) -> None:
|
|
942
|
+
"""Validate an image file before flashing.
|
|
943
|
+
|
|
944
|
+
With ``image_fd`` the open file itself is checked (no path lookup).
|
|
945
|
+
|
|
946
|
+
Raises:
|
|
947
|
+
RuntimeError: If the file is invalid.
|
|
948
|
+
"""
|
|
949
|
+
if image_fd is not None:
|
|
950
|
+
if not stat.S_ISREG(os.fstat(image_fd).st_mode):
|
|
951
|
+
raise RuntimeError(f"Not a regular file: {image_path}")
|
|
952
|
+
elif not os.path.exists(image_path):
|
|
953
|
+
raise RuntimeError(f"Image file not found: {image_path}")
|
|
954
|
+
elif not os.path.isfile(image_path):
|
|
955
|
+
raise RuntimeError(f"Not a regular file: {image_path}")
|
|
956
|
+
|
|
957
|
+
supported = (".img", ".img.xz", ".img.gz")
|
|
958
|
+
name = format_name or image_path
|
|
959
|
+
if not any(name.endswith(ext) for ext in supported):
|
|
960
|
+
raise RuntimeError(
|
|
961
|
+
f"Unsupported image format: {name}. " f"Supported: {', '.join(supported)}"
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
# On-disk signatures of the filesystems whose read-only mount would replay
|
|
966
|
+
# a journal/log: (offset, magic). Used when udev hasn't probed the partition.
|
|
967
|
+
_REPLAYING_FS_MAGIC = {
|
|
968
|
+
"ext4": (1080, b"\x53\xef"), # s_magic 0xEF53 (ext2/3/4)
|
|
969
|
+
"xfs": (0, b"XFSB"),
|
|
970
|
+
"f2fs": (1024, b"\x10\x20\xf5\xf2"), # 0xF2F52010 little-endian
|
|
971
|
+
"btrfs": (65600, b"_BHRfS_M"),
|
|
972
|
+
}
|
|
973
|
+
_FS_PROBE_BYTES = 17 * 4096 # covers the btrfs superblock at 64 KiB
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _fs_by_magic(part_dev: str) -> Optional[str]:
|
|
977
|
+
"""Identify a journal-replaying filesystem from its on-disk signature.
|
|
978
|
+
|
|
979
|
+
Returns a key of ``_REPLAYING_FS_MAGIC``, ``"other"`` if none matches
|
|
980
|
+
(no journal replay to worry about), or None if the partition can't be
|
|
981
|
+
read. Reads the first 68 KiB with ``sudo dd`` (already permitted).
|
|
982
|
+
"""
|
|
983
|
+
try:
|
|
984
|
+
head = subprocess.run(
|
|
985
|
+
[
|
|
986
|
+
"sudo",
|
|
987
|
+
"-n",
|
|
988
|
+
"dd",
|
|
989
|
+
f"if={part_dev}",
|
|
990
|
+
"bs=4096",
|
|
991
|
+
f"count={_FS_PROBE_BYTES // 4096}",
|
|
992
|
+
"status=none",
|
|
993
|
+
],
|
|
994
|
+
capture_output=True,
|
|
995
|
+
timeout=10,
|
|
996
|
+
)
|
|
997
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
998
|
+
return None
|
|
999
|
+
if head.returncode != 0 or not head.stdout:
|
|
1000
|
+
return None
|
|
1001
|
+
data = head.stdout
|
|
1002
|
+
for fstype, (offset, magic) in _REPLAYING_FS_MAGIC.items():
|
|
1003
|
+
if data[offset : offset + len(magic)] == magic:
|
|
1004
|
+
return fstype
|
|
1005
|
+
return "other"
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _ro_no_replay(part_dev: str) -> list[list[str]]:
|
|
1009
|
+
"""Extra mount options, in order to try, so a read-only mount never writes.
|
|
1010
|
+
|
|
1011
|
+
A plain ``-o ro`` mount of ext3/ext4 (and XFS, F2FS, btrfs) still
|
|
1012
|
+
replays a dirty journal/log, which writes to the card; a dirty journal
|
|
1013
|
+
is normal after a board loses power. ``noload`` / ``norecovery`` /
|
|
1014
|
+
``nologreplay`` skip the replay (the view may then miss the last
|
|
1015
|
+
unflushed changes, which is fine for reading).
|
|
1016
|
+
|
|
1017
|
+
The type comes from ``lsblk`` (udev's probe, no root). udev probes
|
|
1018
|
+
asynchronously, so right after a mux switch it may not know yet; then
|
|
1019
|
+
the on-disk signature decides (``_fs_by_magic``). Plain ``ro`` is used
|
|
1020
|
+
only for a filesystem positively identified as non-replaying. If the
|
|
1021
|
+
type can't be determined at all, only ``noload`` is tried: a clear mount
|
|
1022
|
+
failure beats a silent write.
|
|
1023
|
+
"""
|
|
1024
|
+
try:
|
|
1025
|
+
fstype = subprocess.run(
|
|
1026
|
+
["lsblk", "-no", "FSTYPE", part_dev],
|
|
1027
|
+
capture_output=True,
|
|
1028
|
+
text=True,
|
|
1029
|
+
timeout=10,
|
|
1030
|
+
).stdout.strip()
|
|
1031
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1032
|
+
fstype = ""
|
|
1033
|
+
if not fstype:
|
|
1034
|
+
fstype = _fs_by_magic(part_dev) or ""
|
|
1035
|
+
if fstype in {"ext2", "ext3", "ext4"}:
|
|
1036
|
+
return [["noload"]]
|
|
1037
|
+
if fstype in {"xfs", "f2fs"}:
|
|
1038
|
+
return [["norecovery"]]
|
|
1039
|
+
if fstype == "btrfs":
|
|
1040
|
+
# Both skip log replay: rescue=nologreplay on kernels >= 5.9,
|
|
1041
|
+
# nologreplay before that.
|
|
1042
|
+
return [["rescue=nologreplay"], ["nologreplay"]]
|
|
1043
|
+
if fstype:
|
|
1044
|
+
return [[]] # identified, non-replaying (e.g. vfat, exfat, "other")
|
|
1045
|
+
return [["noload"]]
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _block_device_has_media(block_dev: str) -> bool:
|
|
1049
|
+
"""Check if a block device has actual media (non-zero size)."""
|
|
1050
|
+
if not block_dev or not os.path.exists(block_dev):
|
|
1051
|
+
return False
|
|
1052
|
+
size_path = f"/sys/block/{os.path.basename(block_dev)}/size"
|
|
1053
|
+
try:
|
|
1054
|
+
with open(size_path) as f:
|
|
1055
|
+
return int(f.read().strip()) > 0
|
|
1056
|
+
except (OSError, ValueError):
|
|
1057
|
+
return False
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def discover_sdwire_devices() -> list[dict]:
|
|
1061
|
+
"""Discover all connected SDWire devices.
|
|
1062
|
+
|
|
1063
|
+
Returns a list of dicts with serial_number, device_type, and block_dev.
|
|
1064
|
+
Detects both SDWireC (Realtek) and legacy SDWire (FTDI) devices.
|
|
1065
|
+
"""
|
|
1066
|
+
try:
|
|
1067
|
+
from sdwire.backend.detect import (
|
|
1068
|
+
get_sdwire_devices,
|
|
1069
|
+
get_sdwirec_devices,
|
|
1070
|
+
)
|
|
1071
|
+
except ImportError as e:
|
|
1072
|
+
if not is_missing(e, "sdwire"):
|
|
1073
|
+
raise BrokenExtraError("sdwire", e) from e
|
|
1074
|
+
raise MissingExtraError("sdwire") from e
|
|
1075
|
+
|
|
1076
|
+
results = []
|
|
1077
|
+
seen_serials = set()
|
|
1078
|
+
|
|
1079
|
+
# Legacy SDWire (FTDI-based, VID 04E8:6001)
|
|
1080
|
+
try:
|
|
1081
|
+
for dev in get_sdwirec_devices():
|
|
1082
|
+
serial = dev.serial_string
|
|
1083
|
+
if serial and serial not in seen_serials:
|
|
1084
|
+
seen_serials.add(serial)
|
|
1085
|
+
results.append(
|
|
1086
|
+
{
|
|
1087
|
+
"serial_number": serial,
|
|
1088
|
+
"device_type": "sdwire",
|
|
1089
|
+
"product": getattr(dev, "product_string", ""),
|
|
1090
|
+
"manufacturer": getattr(dev, "manufacturer_string", ""),
|
|
1091
|
+
"block_dev": getattr(dev, "block_dev", None),
|
|
1092
|
+
}
|
|
1093
|
+
)
|
|
1094
|
+
except Exception:
|
|
1095
|
+
pass
|
|
1096
|
+
|
|
1097
|
+
# SDWireC (Realtek-based, VID 0BDA:0316)
|
|
1098
|
+
# get_sdwire_devices() also returns legacy devices, so deduplicate
|
|
1099
|
+
try:
|
|
1100
|
+
for dev in get_sdwire_devices():
|
|
1101
|
+
serial = dev.serial_string
|
|
1102
|
+
if serial and serial not in seen_serials:
|
|
1103
|
+
seen_serials.add(serial)
|
|
1104
|
+
results.append(
|
|
1105
|
+
{
|
|
1106
|
+
"serial_number": serial,
|
|
1107
|
+
"device_type": "sdwirec",
|
|
1108
|
+
"product": getattr(dev, "product_string", ""),
|
|
1109
|
+
"manufacturer": getattr(dev, "manufacturer_string", ""),
|
|
1110
|
+
"block_dev": getattr(dev, "block_dev", None),
|
|
1111
|
+
}
|
|
1112
|
+
)
|
|
1113
|
+
except Exception:
|
|
1114
|
+
pass
|
|
1115
|
+
|
|
1116
|
+
return results
|