easymanet 0.1.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.
Files changed (42) hide show
  1. easymanet/__init__.py +3 -0
  2. easymanet/disks/__init__.py +73 -0
  3. easymanet/disks/_common.py +88 -0
  4. easymanet/disks/core.py +139 -0
  5. easymanet/disks/linux.py +306 -0
  6. easymanet/disks/macos.py +343 -0
  7. easymanet/download.py +563 -0
  8. easymanet/format.py +13 -0
  9. easymanet/image.py +323 -0
  10. easymanet/inject.py +322 -0
  11. easymanet/manifest.py +91 -0
  12. easymanet/platform.py +25 -0
  13. easymanet/privileges.py +39 -0
  14. easymanet/render.py +60 -0
  15. easymanet/validate.py +338 -0
  16. easymanet/workspace.py +132 -0
  17. easymanet-0.1.0.data/data/share/easymanet/images/openmanet/provisioning/extra-packages.txt +6 -0
  18. easymanet-0.1.0.data/data/share/easymanet/images/openmanet/provisioning/openwrt-overlay/README.md +18 -0
  19. easymanet-0.1.0.data/data/share/easymanet/images/openmanet/provisioning/openwrt-overlay/etc/easymanet/provision.json +3 -0
  20. easymanet-0.1.0.data/data/share/easymanet/images/openmanet/provisioning/openwrt-overlay/etc/init.d/easymanet-boot-report +13 -0
  21. easymanet-0.1.0.data/data/share/easymanet/images/openmanet/provisioning/openwrt-overlay/etc/init.d/easymanet-management-lan +22 -0
  22. easymanet-0.1.0.data/data/share/easymanet/images/openmanet/provisioning/openwrt-overlay/etc/sysctl.d/99-easymanet.conf +18 -0
  23. easymanet-0.1.0.data/data/share/easymanet/images/openmanet/provisioning/openwrt-overlay/etc/uci-defaults/97-easymanet-management-lan +7 -0
  24. easymanet-0.1.0.data/data/share/easymanet/images/openmanet/provisioning/openwrt-overlay/etc/uci-defaults/98-easymanet-boot-report +8 -0
  25. easymanet-0.1.0.data/data/share/easymanet/images/openmanet/provisioning/openwrt-overlay/etc/uci-defaults/99-easymanet +24 -0
  26. easymanet-0.1.0.data/data/share/easymanet/images/openmanet/provisioning/openwrt-overlay/usr/lib/easymanet/boot-report.sh +125 -0
  27. easymanet-0.1.0.data/data/share/easymanet/images/openmanet/provisioning/openwrt-overlay/usr/lib/easymanet/network.sh +86 -0
  28. easymanet-0.1.0.data/data/share/easymanet/images/openmanet/provisioning/openwrt-overlay/usr/lib/easymanet/provision-lib.sh +75 -0
  29. easymanet-0.1.0.data/data/share/easymanet/images/openmanet/provisioning/openwrt-overlay/usr/lib/easymanet/provision.sh +532 -0
  30. easymanet-0.1.0.dist-info/METADATA +59 -0
  31. easymanet-0.1.0.dist-info/RECORD +42 -0
  32. easymanet-0.1.0.dist-info/WHEEL +5 -0
  33. easymanet-0.1.0.dist-info/entry_points.txt +2 -0
  34. easymanet-0.1.0.dist-info/top_level.txt +3 -0
  35. easymanet_cli/__init__.py +1 -0
  36. easymanet_cli/app.py +162 -0
  37. easymanet_cli/common.py +57 -0
  38. easymanet_cli/flash.py +437 -0
  39. easymanet_image/__init__.py +1 -0
  40. easymanet_image/build.py +410 -0
  41. easymanet_image/cli.py +240 -0
  42. easymanet_image/release.py +105 -0
easymanet/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """EasyMANET - Zero-touch OpenMANET provisioning and imaging."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,73 @@
1
+ """Disk detection and listing for macOS and Linux."""
2
+
3
+ import glob
4
+ import os
5
+ import subprocess
6
+
7
+ from ..platform import is_linux, is_macos
8
+ from ._common import (
9
+ DISK_PARSE_ERRORS,
10
+ DISK_SUSPICIOUS_SIZE_GB,
11
+ DISK_WARN_THRESHOLD_GB,
12
+ OVERLAY_WIPE_BLOCK_MIB,
13
+ OVERLAY_WIPE_BLOCKS,
14
+ SYS_MOUNT_POINTS,
15
+ DiskInfo,
16
+ debug_note,
17
+ )
18
+ from .core import (
19
+ assert_flash_allowed,
20
+ eject_disk,
21
+ find_disk,
22
+ get_partition2_wipe_range,
23
+ list_disks,
24
+ lookup_device,
25
+ unmount_disk,
26
+ )
27
+ from .linux import (
28
+ _check_linux_system_disk,
29
+ _findmnt_source,
30
+ _linux_base_block_device,
31
+ _linux_disk_from_lsblk,
32
+ _linux_lsblk_pkname,
33
+ _linux_partition2_wipe_range,
34
+ _linux_partitions_for_device,
35
+ _linux_resolve_findmnt_source,
36
+ _linux_root_block_devices,
37
+ _linux_should_list_default,
38
+ list_disks_linux,
39
+ lookup_device_linux,
40
+ unmount_disk_linux,
41
+ )
42
+ from .macos import (
43
+ _macos_partition2_wipe_range,
44
+ get_macos_partitions,
45
+ list_disks_macos,
46
+ lookup_device_macos,
47
+ unmount_disk_macos,
48
+ )
49
+ from .core import _is_block_device
50
+
51
+ __all__ = [
52
+ "DISK_PARSE_ERRORS",
53
+ "DISK_SUSPICIOUS_SIZE_GB",
54
+ "DISK_WARN_THRESHOLD_GB",
55
+ "DiskInfo",
56
+ "OVERLAY_WIPE_BLOCK_MIB",
57
+ "OVERLAY_WIPE_BLOCKS",
58
+ "SYS_MOUNT_POINTS",
59
+ "assert_flash_allowed",
60
+ "debug_note",
61
+ "eject_disk",
62
+ "find_disk",
63
+ "get_macos_partitions",
64
+ "get_partition2_wipe_range",
65
+ "glob",
66
+ "is_linux",
67
+ "is_macos",
68
+ "list_disks",
69
+ "lookup_device",
70
+ "os",
71
+ "subprocess",
72
+ "unmount_disk",
73
+ ]
@@ -0,0 +1,88 @@
1
+ """Shared disk types and helpers."""
2
+
3
+ import plistlib
4
+ import subprocess
5
+ import sys
6
+ from typing import List, Optional
7
+
8
+ _SUBPROCESS_ERRORS = (
9
+ subprocess.CalledProcessError,
10
+ subprocess.TimeoutExpired,
11
+ FileNotFoundError,
12
+ )
13
+ DISK_PARSE_ERRORS = (
14
+ *_SUBPROCESS_ERRORS,
15
+ OSError,
16
+ ValueError,
17
+ UnicodeDecodeError,
18
+ plistlib.InvalidFileException,
19
+ )
20
+
21
+ DISK_WARN_THRESHOLD_GB = 128
22
+ DISK_SUSPICIOUS_SIZE_GB = 256
23
+
24
+ SYS_MOUNT_POINTS = frozenset({"/", "/boot", "/boot/efi", "/home", "/var", "/usr"})
25
+
26
+ OVERLAY_WIPE_BLOCK_MIB = 16
27
+ OVERLAY_WIPE_BLOCKS = 288
28
+
29
+
30
+ def debug_note(message: str) -> None:
31
+ print(f"easymanet: {message}", file=sys.stderr)
32
+
33
+
34
+ class DiskInfo:
35
+ def __init__(
36
+ self,
37
+ device: str,
38
+ size_bytes: int = 0,
39
+ model: str = "",
40
+ removable: bool = False,
41
+ mounted: Optional[List[str]] = None,
42
+ is_system: bool = False,
43
+ not_in_default_list: bool = False,
44
+ ):
45
+ self.device = device
46
+ self.size_bytes = size_bytes
47
+ self.model = model
48
+ self.removable = removable
49
+ self.mounted = mounted or []
50
+ self.is_system = is_system
51
+ self.not_in_default_list = not_in_default_list
52
+
53
+ @property
54
+ def size_gb(self) -> float:
55
+ return self.size_bytes / (1024 ** 3)
56
+
57
+ @property
58
+ def size_human(self) -> str:
59
+ gb = self.size_gb
60
+ if gb < 1:
61
+ mb = self.size_bytes / (1024 ** 2)
62
+ return f"{mb:.1f} MB"
63
+ return f"{gb:.1f} GB"
64
+
65
+ @property
66
+ def blocking_warnings(self) -> List[str]:
67
+ w: List[str] = []
68
+ if self.is_system:
69
+ w.append(
70
+ "WARNING: This appears to be a system disk — use --force to override"
71
+ )
72
+ elif not self.removable and self.size_gb > DISK_WARN_THRESHOLD_GB:
73
+ w.append(
74
+ "WARNING: Large fixed disk — use --force to proceed"
75
+ )
76
+ elif self.size_gb > DISK_SUSPICIOUS_SIZE_GB:
77
+ w.append(
78
+ "WARNING: Suspiciously large device — use --force to proceed"
79
+ )
80
+ if self.not_in_default_list:
81
+ w.append(
82
+ "WARNING: Device not in default disk list — use --force to proceed"
83
+ )
84
+ return w
85
+
86
+ @property
87
+ def warnings(self) -> List[str]:
88
+ return self.blocking_warnings
@@ -0,0 +1,139 @@
1
+ """Cross-platform disk listing and flash safety."""
2
+
3
+ import os
4
+ import stat
5
+ import subprocess
6
+ import sys
7
+ from typing import List, Optional, Tuple
8
+
9
+ from ._common import (
10
+ OVERLAY_WIPE_BLOCK_MIB,
11
+ OVERLAY_WIPE_BLOCKS,
12
+ DiskInfo,
13
+ )
14
+
15
+
16
+ def _disks_module():
17
+ return sys.modules[__package__]
18
+
19
+
20
+ def _is_block_device(path: str) -> bool:
21
+ if not os.path.exists(path):
22
+ return False
23
+ try:
24
+ return stat.S_ISBLK(os.stat(path).st_mode)
25
+ except OSError:
26
+ return False
27
+
28
+
29
+ def list_disks(include_all: bool = False) -> List[DiskInfo]:
30
+ disks_mod = _disks_module()
31
+ if disks_mod.is_macos():
32
+ disks = disks_mod.list_disks_macos(include_all=include_all)
33
+ elif disks_mod.is_linux():
34
+ disks = disks_mod.list_disks_linux(include_all=include_all)
35
+ else:
36
+ return []
37
+ return sorted(disks, key=lambda d: d.size_bytes, reverse=True)
38
+
39
+
40
+ def lookup_device(
41
+ device: str,
42
+ default_disks: Optional[List[DiskInfo]] = None,
43
+ ) -> Optional[DiskInfo]:
44
+ disks_mod = _disks_module()
45
+ if disks_mod.is_macos():
46
+ disk = disks_mod.lookup_device_macos(device)
47
+ elif disks_mod.is_linux():
48
+ disk = disks_mod.lookup_device_linux(device)
49
+ else:
50
+ disk = None
51
+
52
+ if disk is None:
53
+ return None
54
+
55
+ disks = (
56
+ default_disks
57
+ if default_disks is not None
58
+ else disks_mod.list_disks(include_all=False)
59
+ )
60
+ if not any(d.device == device for d in disks):
61
+ disk.not_in_default_list = True
62
+ return disk
63
+
64
+
65
+ def find_disk(device: str) -> Optional[DiskInfo]:
66
+ disks = list_disks()
67
+ for disk in disks:
68
+ if disk.device == device:
69
+ return disk
70
+ return lookup_device(device, default_disks=disks)
71
+
72
+
73
+ def assert_flash_allowed(device: str, force: bool = False) -> DiskInfo:
74
+ if not _disks_module()._is_block_device(device):
75
+ raise ValueError(
76
+ f"Device {device} does not exist or is not a block device."
77
+ )
78
+
79
+ disk = _disks_module().lookup_device(device)
80
+ if disk is None:
81
+ raise ValueError(
82
+ f"Could not read disk information for {device}."
83
+ )
84
+
85
+ blocking = disk.blocking_warnings
86
+ if blocking and not force:
87
+ lines = "\n".join(f" {w}" for w in blocking)
88
+ raise ValueError(
89
+ f"Refusing to flash {device}:\n{lines}\n"
90
+ f" Model: {disk.model}\n"
91
+ f" Size: {disk.size_human}\n"
92
+ f" Mounted: {', '.join(disk.mounted) if disk.mounted else 'none'}\n"
93
+ f"Use --force to override."
94
+ )
95
+ return disk
96
+
97
+
98
+ def unmount_disk(device: str) -> None:
99
+ disks_mod = _disks_module()
100
+ if disks_mod.is_macos():
101
+ disks_mod.unmount_disk_macos(device)
102
+ elif disks_mod.is_linux():
103
+ disks_mod.unmount_disk_linux(device)
104
+
105
+
106
+ def get_partition2_wipe_range(device: str) -> Optional[Tuple[int, int]]:
107
+ """Return (start_byte_offset, wipe_bytes) for partition 2 stale-overlay tail wipe."""
108
+ max_wipe = OVERLAY_WIPE_BLOCK_MIB * OVERLAY_WIPE_BLOCKS * 1024 * 1024
109
+ disks_mod = _disks_module()
110
+
111
+ if disks_mod.is_linux():
112
+ return disks_mod._linux_partition2_wipe_range(device, max_wipe)
113
+ if disks_mod.is_macos():
114
+ return disks_mod._macos_partition2_wipe_range(device, max_wipe)
115
+ return None
116
+
117
+
118
+ def eject_disk(device: str) -> None:
119
+ disks_mod = _disks_module()
120
+ if disks_mod.is_macos():
121
+ result = subprocess.run(
122
+ ["diskutil", "eject", device],
123
+ capture_output=True,
124
+ text=True,
125
+ timeout=30,
126
+ )
127
+ elif disks_mod.is_linux():
128
+ result = subprocess.run(
129
+ ["eject", device],
130
+ capture_output=True,
131
+ text=True,
132
+ timeout=30,
133
+ )
134
+ else:
135
+ return
136
+ if result.returncode != 0:
137
+ detail = (result.stderr or result.stdout or "").strip()
138
+ suffix = f": {detail}" if detail else ""
139
+ raise RuntimeError(f"Failed to eject {device}{suffix}")
@@ -0,0 +1,306 @@
1
+ """Linux disk discovery and safety helpers."""
2
+
3
+ import glob
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from typing import List, Optional, Tuple
10
+
11
+ from ._common import DISK_PARSE_ERRORS, DiskInfo, debug_note
12
+
13
+ _FINDMNT_PARENT_RESOLUTION_MAX_DEPTH = 8
14
+ _LINUX_DEFAULT_LOGICAL_SECTOR_BYTES = 512
15
+
16
+
17
+ def _disks_module():
18
+ return sys.modules[__package__]
19
+
20
+
21
+ def _parse_lsblk_size(size_str: str) -> int:
22
+ try:
23
+ return int(size_str)
24
+ except ValueError:
25
+ suffixes = {"K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4}
26
+ try:
27
+ num = float(size_str[:-1])
28
+ suffix = size_str[-1].upper()
29
+ return int(num * suffixes.get(suffix, 1))
30
+ except (ValueError, IndexError):
31
+ return 0
32
+
33
+
34
+ def _get_linux_mounts(dev: dict) -> List[str]:
35
+ mounts = []
36
+ children = dev.get("children", [])
37
+ for child in children:
38
+ mp = child.get("mountpoint")
39
+ if mp:
40
+ mounts.append(mp)
41
+ return mounts
42
+
43
+
44
+ def _linux_disk_from_lsblk(dev: dict) -> DiskInfo:
45
+ dev_name = dev.get("name", "")
46
+ dev_path = f"/dev/{dev_name}"
47
+ model = (dev.get("model") or "").strip() or dev_name
48
+ removable = dev.get("rm", "0") == "1"
49
+ tran = (dev.get("tran") or "").lower()
50
+ if tran in ("usb", "mmc"):
51
+ removable = True
52
+ size_bytes = _parse_lsblk_size(dev.get("size", "0"))
53
+ mounted = _get_linux_mounts(dev)
54
+ is_system = _check_linux_system_disk(dev_path, mounted)
55
+ return DiskInfo(
56
+ device=dev_path,
57
+ size_bytes=size_bytes,
58
+ model=model,
59
+ removable=removable,
60
+ mounted=mounted,
61
+ is_system=is_system,
62
+ )
63
+
64
+
65
+ def _linux_should_list_default(dev: dict) -> bool:
66
+ if dev.get("type") != "disk":
67
+ return False
68
+ if dev.get("rm", "0") == "1":
69
+ return True
70
+ tran = (dev.get("tran") or "").lower()
71
+ return tran in ("usb", "mmc")
72
+
73
+
74
+ def _linux_lsblk_data(device: Optional[str] = None) -> Optional[dict]:
75
+ cmd = [
76
+ "lsblk",
77
+ "-J",
78
+ "-o",
79
+ "NAME,SIZE,TYPE,MOUNTPOINT,MODEL,RM,ROTA,TRAN",
80
+ ]
81
+ if device:
82
+ cmd.extend(["-n", device])
83
+ try:
84
+ output = subprocess.check_output(cmd, timeout=10).decode()
85
+ return json.loads(output)
86
+ except (
87
+ subprocess.CalledProcessError,
88
+ FileNotFoundError,
89
+ json.JSONDecodeError,
90
+ subprocess.TimeoutExpired,
91
+ ):
92
+ return None
93
+
94
+
95
+ def list_disks_linux(include_all: bool = False) -> List[DiskInfo]:
96
+ data = _linux_lsblk_data()
97
+ if not data:
98
+ return []
99
+
100
+ disks: List[DiskInfo] = []
101
+ for dev in data.get("blockdevices", []):
102
+ if dev.get("type") != "disk":
103
+ continue
104
+ if not include_all and not _linux_should_list_default(dev):
105
+ continue
106
+ disks.append(_linux_disk_from_lsblk(dev))
107
+
108
+ return sorted(disks, key=lambda d: d.size_bytes, reverse=True)
109
+
110
+
111
+ def lookup_device_linux(device: str) -> Optional[DiskInfo]:
112
+ data = _linux_lsblk_data(device)
113
+ if not data:
114
+ return None
115
+ blockdevs = data.get("blockdevices", [])
116
+ if not blockdevs:
117
+ return None
118
+ dev = blockdevs[0]
119
+ if dev.get("type") != "disk":
120
+ return None
121
+ return _linux_disk_from_lsblk(dev)
122
+
123
+
124
+ def _findmnt_source(mount_point: str) -> Optional[str]:
125
+ try:
126
+ output = subprocess.check_output(
127
+ ["findmnt", "-n", "-o", "SOURCE", mount_point],
128
+ stderr=subprocess.DEVNULL,
129
+ timeout=5,
130
+ ).decode()
131
+ return output.strip() or None
132
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
133
+ return None
134
+
135
+
136
+ def _linux_base_block_device(source: str) -> Optional[str]:
137
+ if not source.startswith("/dev/"):
138
+ return None
139
+ match = re.match(r"^(?P<base>/dev/(?:mmcblk\d+|nvme\d+n\d+))p\d+$", source)
140
+ if match:
141
+ return match.group("base")
142
+ match = re.match(r"^(?P<base>/dev/[a-z]+)\d+$", source)
143
+ if match:
144
+ return match.group("base")
145
+ if re.match(r"^/dev/(?:mmcblk\d+|nvme\d+n\d+|[a-z]+)$", source):
146
+ return source
147
+ return None
148
+
149
+
150
+ def _linux_lsblk_pkname(device: str) -> Optional[str]:
151
+ try:
152
+ output = subprocess.check_output(
153
+ ["lsblk", "-no", "PKNAME", device],
154
+ stderr=subprocess.DEVNULL,
155
+ timeout=5,
156
+ ).decode()
157
+ name = output.strip()
158
+ if name:
159
+ return f"/dev/{name}"
160
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
161
+ return None
162
+ return None
163
+
164
+
165
+ def _linux_resolve_findmnt_source(source: str) -> Optional[str]:
166
+ disks_mod = _disks_module()
167
+ device_path: Optional[str] = None
168
+ if source.startswith("UUID="):
169
+ uuid = source.split("=", 1)[1]
170
+ by_uuid = f"/dev/disk/by-uuid/{uuid}"
171
+ if disks_mod.os.path.exists(by_uuid):
172
+ device_path = disks_mod.os.path.realpath(by_uuid)
173
+ elif source.startswith("PARTUUID="):
174
+ partuuid = source.split("=", 1)[1]
175
+ by_partuuid = f"/dev/disk/by-partuuid/{partuuid}"
176
+ if disks_mod.os.path.exists(by_partuuid):
177
+ device_path = disks_mod.os.path.realpath(by_partuuid)
178
+ elif source.startswith("/dev/"):
179
+ device_path = disks_mod.os.path.realpath(source)
180
+ else:
181
+ return None
182
+
183
+ if not device_path:
184
+ return None
185
+
186
+ current = device_path
187
+ for _ in range(_FINDMNT_PARENT_RESOLUTION_MAX_DEPTH):
188
+ base = disks_mod._linux_base_block_device(current)
189
+ if base:
190
+ return base
191
+ parent = disks_mod._linux_lsblk_pkname(current)
192
+ if not parent or parent == current:
193
+ break
194
+ current = parent
195
+ return None
196
+
197
+
198
+ def _linux_partitions_for_device(device: str) -> List[str]:
199
+ partitions = set()
200
+ for pattern in (f"{device}[0-9]*", f"{device}p[0-9]*"):
201
+ partitions.update(_disks_module().glob.glob(pattern))
202
+ partitions.discard(device)
203
+ return sorted(partitions)
204
+
205
+
206
+ def _linux_root_block_devices() -> set:
207
+ disks_mod = _disks_module()
208
+ related: set = set()
209
+ for mount_point in sorted(disks_mod.SYS_MOUNT_POINTS):
210
+ source = disks_mod._findmnt_source(mount_point)
211
+ if not source:
212
+ continue
213
+ base = disks_mod._linux_resolve_findmnt_source(source)
214
+ if not base:
215
+ continue
216
+ related.add(base)
217
+ related.update(disks_mod._linux_partitions_for_device(base))
218
+ if source.startswith("/dev/"):
219
+ resolved = disks_mod.os.path.realpath(source)
220
+ if resolved.startswith("/dev/"):
221
+ related.add(resolved)
222
+ return related
223
+
224
+
225
+ def _check_linux_system_disk(dev_path: str, mounts: List[str]) -> bool:
226
+ disks_mod = _disks_module()
227
+ if any(mp in disks_mod.SYS_MOUNT_POINTS for mp in mounts):
228
+ return True
229
+
230
+ root_related = disks_mod._linux_root_block_devices()
231
+ if not root_related:
232
+ debug_note(
233
+ "could not resolve Linux root block devices; treating candidate as a system disk"
234
+ )
235
+ return True
236
+
237
+ if dev_path in root_related:
238
+ return True
239
+ if set(disks_mod._linux_partitions_for_device(dev_path)) & root_related:
240
+ return True
241
+ for entry in root_related:
242
+ if disks_mod._linux_base_block_device(entry) == dev_path:
243
+ return True
244
+ return False
245
+
246
+
247
+ def _linux_partition2_wipe_range(device: str, max_wipe: int) -> Optional[Tuple[int, int]]:
248
+ cmd = ["lsblk", "-J", "-b", "-o", "NAME,START,SIZE,TYPE,LOG-SEC", "-n", device]
249
+ try:
250
+ output = subprocess.check_output(cmd, timeout=10).decode()
251
+ data = json.loads(output)
252
+ except (
253
+ subprocess.CalledProcessError,
254
+ FileNotFoundError,
255
+ json.JSONDecodeError,
256
+ subprocess.TimeoutExpired,
257
+ ):
258
+ return None
259
+
260
+ blockdevs = data.get("blockdevices", [])
261
+ if not blockdevs:
262
+ return None
263
+ children = blockdevs[0].get("children", [])
264
+ parts = [c for c in children if c.get("type") == "part"]
265
+ if len(parts) < 2:
266
+ return None
267
+ part2 = sorted(parts, key=lambda p: int(p.get("start", 0) or 0))[1]
268
+ start = int(part2.get("start", 0) or 0)
269
+ size = int(part2.get("size", 0) or 0)
270
+ if start <= 0 or size <= 0:
271
+ return None
272
+ sector_bytes = _linux_logical_sector_bytes(part2, blockdevs[0])
273
+ start_bytes = start * sector_bytes
274
+ wipe_bytes = min(size, max_wipe)
275
+ tail_start = start_bytes + size - wipe_bytes
276
+ return (tail_start, wipe_bytes)
277
+
278
+
279
+ def _linux_logical_sector_bytes(*devices: dict) -> int:
280
+ for dev in devices:
281
+ for key in ("log-sec", "log_sec", "LOG-SEC"):
282
+ try:
283
+ value = int(dev.get(key, 0) or 0)
284
+ except (TypeError, ValueError):
285
+ continue
286
+ if value > 0:
287
+ return value
288
+ return _LINUX_DEFAULT_LOGICAL_SECTOR_BYTES
289
+
290
+
291
+ def unmount_disk_linux(device: str) -> None:
292
+ targets = _linux_partitions_for_device(device) or [device]
293
+ for target in targets:
294
+ result = subprocess.run(
295
+ ["umount", target],
296
+ capture_output=True,
297
+ text=True,
298
+ timeout=60,
299
+ )
300
+ if result.returncode == 0:
301
+ continue
302
+ detail = (result.stderr or result.stdout or "").strip()
303
+ if "not mounted" in detail.lower():
304
+ continue
305
+ suffix = f": {detail}" if detail else ""
306
+ raise RuntimeError(f"Failed to unmount {target}{suffix}")