dwipe 2.0.0__py3-none-any.whl → 2.0.1__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.
dwipe/DeviceInfo.py CHANGED
@@ -1,6 +1,8 @@
1
1
  """
2
2
  DeviceInfo class for device discovery and information management
3
3
  """
4
+ import os
5
+ import re
4
6
  import json
5
7
  import subprocess
6
8
  import time
@@ -36,6 +38,7 @@ class DeviceInfo:
36
38
  dflt=dflt, # default run-time state
37
39
  label='', # blkid
38
40
  fstype='', # blkid
41
+ type='', # device type (disk, part)
39
42
  model='', # /sys/class/block/{name}/device/vendor|model
40
43
  size_bytes=size_bytes, # /sys/block/{name}/...
41
44
  marker='', # persistent status
@@ -45,10 +48,59 @@ class DeviceInfo:
45
48
  job=None, # if zap running
46
49
  uuid='', # filesystem UUID or PARTUUID
47
50
  serial='', # disk serial number (for whole disks)
51
+ port='', # port (for whole disks)
48
52
  )
49
53
 
54
+ def _get_port_from_sysfs(self, device_name):
55
+ try:
56
+ sysfs_path = f'/sys/class/block/{device_name}'
57
+ if not os.path.exists(sysfs_path):
58
+ return ''
59
+
60
+ real_path = os.path.realpath(sysfs_path).lower()
61
+
62
+ # 1. USB - Format: USB:1-1.4
63
+ if '/usb' in real_path:
64
+ usb_match = re.search(r'/(\d+-\d+(?:\.\d+)*):', real_path)
65
+ if usb_match:
66
+ return f"USB:{usb_match.group(1)}"
67
+
68
+ # 2. SATA - Format: SATA:1
69
+ elif '/ata' in real_path:
70
+ ata_match = re.search(r'ata(\d+)', real_path)
71
+ if ata_match:
72
+ return f"SATA:{ata_match.group(1)}"
73
+
74
+ # 3. NVMe - Format: PCI:1b.0 (Stripped of 0000:00: noise)
75
+ elif '/nvme' in real_path:
76
+ # This regex ignores the 4-digit domain and the first 2-digit bus
77
+ pci_match = re.search(r'0000:[0-9a-f]{2}:([0-9a-f]{2}\.[0-9a-f])', real_path)
78
+ if pci_match:
79
+ return f"PCI:{pci_match.group(1)}"
80
+ return "NVMe"
81
+
82
+ # 4. MMC/eMMC - Format: MMC:0 or PCI:1a.0 (if PCI-attached)
83
+ elif '/mmc' in real_path:
84
+ # Try to extract mmc host number
85
+ mmc_match = re.search(r'/mmc_host/mmc(\d+)', real_path)
86
+ if mmc_match:
87
+ return f"MMC:{mmc_match.group(1)}"
88
+ # Fallback: try to get PCI address if available
89
+ pci_match = re.search(r'0000:[0-9a-f]{2}:([0-9a-f]{2}\.[0-9a-f])', real_path)
90
+ if pci_match:
91
+ return f"PCI:{pci_match.group(1)}"
92
+ return "MMC"
93
+
94
+ except Exception as e:
95
+ # Log exception to file for debugging
96
+ with open('/tmp/dwipe_port_debug.log', 'a', encoding='utf-8') as f:
97
+ import traceback
98
+ f.write(f"Exception in _get_port_from_sysfs({device_name}): {e}\n")
99
+ traceback.print_exc(file=f)
100
+ return ''
101
+
50
102
  @staticmethod
51
- def get_device_vendor_model(device_name):
103
+ def _get_device_vendor_model(device_name):
52
104
  """Gets the vendor and model for a given device from the /sys/class/block directory.
53
105
  - Args: - device_name: The device name, such as 'sda', 'sdb', etc.
54
106
  - Returns: A string containing the vendor and model information.
@@ -156,20 +208,51 @@ class DeviceInfo:
156
208
  # Parse each block device and its properties
157
209
  for device in parsed_data['blockdevices']:
158
210
  parent = eat_one(device)
159
- parent.fstype = self.get_device_vendor_model(parent.name)
160
211
  entries[parent.name] = parent
161
212
  for child in device.get('children', []):
162
213
  entry = eat_one(child)
163
214
  entries[entry.name] = entry
164
215
  entry.parent = parent.name
165
216
  parent.minors.append(entry.name)
166
- if not parent.fstype:
167
- parent.fstype = 'DISK'
168
217
  self.disk_majors.add(entry.major)
169
218
  if entry.mounts:
170
219
  entry.state = 'Mnt'
171
220
  parent.state = 'Mnt'
172
221
 
222
+
223
+ # Final pass: Identify disks, assign ports, and handle superfloppies
224
+ final_entries = {}
225
+ for name, entry in entries.items():
226
+ final_entries[name] = entry
227
+
228
+ # Only process top-level physical disks
229
+ if entry.parent is None:
230
+ # Hardware Info Gathering
231
+ entry.model = self._get_device_vendor_model(entry.name)
232
+ entry.port = self._get_port_from_sysfs(entry.name)
233
+
234
+ # The Split (Superfloppy Case)
235
+ # If it has children, the children already hold the data.
236
+ # If it has NO children but HAS data, we create the '----' child.
237
+ if not entry.minors and (entry.fstype or entry.label or entry.mounts):
238
+ v_key = f"{name}_data"
239
+ v_child = self._make_partition_namespace(entry.major, name, entry.size_bytes, dflt)
240
+ v_child.name = "----"
241
+ v_child.fstype = entry.fstype
242
+ v_child.label = entry.label
243
+ v_child.mounts = entry.mounts
244
+ v_child.parent = name
245
+
246
+ final_entries[v_key] = v_child
247
+ entry.minors.append(v_key)
248
+
249
+ # Clean the hardware row of data-specific strings
250
+ entry.fstype = entry.model if entry.model else 'DISK'
251
+ entry.label = ''
252
+ entry.mounts = []
253
+
254
+ entries = final_entries
255
+
173
256
  if self.DB:
174
257
  print('\n\nDB: --->>> after parse_lsblk:')
175
258
  for entry in entries.values():
@@ -237,21 +320,38 @@ class DeviceInfo:
237
320
  DeviceInfo.set_one_state(nss, ns)
238
321
 
239
322
  def get_disk_partitions(self, nss):
240
- """Filter out virtual/pseudo devices (zram, loop, etc.) while keeping physical drives.
241
- Keeps: sd*, nvme*, vd*, hd*, mmcblk* (physical and USB drives)
242
- Excludes: zram*, loop*, dm-*, sr*, ram*
243
- """
244
- # Virtual/pseudo device prefixes to exclude
245
- exclude_prefixes = ('zram', 'loop', 'dm-', 'sr', 'ram')
323
+ """Filter to only wipeable physical storage using positive criteria.
246
324
 
325
+ Keeps devices that:
326
+ - Are type 'disk' or 'part' (from lsblk)
327
+ - Are writable (not read-only)
328
+ - Are real block devices (not virtual)
329
+
330
+ This automatically excludes:
331
+ - Virtual devices (zram, loop, dm-*, etc.)
332
+ - Read-only devices (CD-ROMs, eMMC boot partitions)
333
+ - Special partitions (boot loaders)
334
+ """
247
335
  ok_nss = {}
248
336
  for name, ns in nss.items():
249
337
  # Must be disk or partition type
250
338
  if ns.type not in ('disk', 'part'):
251
339
  continue
252
340
 
253
- # Exclude virtual/pseudo devices by name prefix
254
- if any(name.startswith(prefix) for prefix in exclude_prefixes):
341
+ # Must be writable (excludes CD-ROMs, eMMC boot partitions, etc.)
342
+ ro_path = f'/sys/class/block/{name}/ro'
343
+ try:
344
+ with open(ro_path, 'r', encoding='utf-8') as f:
345
+ if f.read().strip() != '0':
346
+ continue # Skip read-only devices
347
+ except (FileNotFoundError, Exception):
348
+ # If we can't read ro flag, skip this device to be safe
349
+ continue
350
+
351
+ # Exclude common virtual device prefixes as a safety net
352
+ # (most should already be filtered by ro check or missing sysfs)
353
+ virtual_prefixes = ('zram', 'loop', 'dm-', 'ram')
354
+ if any(name.startswith(prefix) for prefix in virtual_prefixes):
255
355
  continue
256
356
 
257
357
  # Include this device
@@ -405,6 +505,7 @@ class DeviceInfo:
405
505
  if new_ns:
406
506
  if prev_ns.job:
407
507
  new_ns.job = prev_ns.job
508
+ # Note: Do NOT preserve port - use fresh value from current scan
408
509
  new_ns.dflt = prev_ns.dflt
409
510
  # Preserve the "wiped this session" flag
410
511
  if hasattr(prev_ns, 'wiped_this_session'):
dwipe/DiskWipe.py CHANGED
@@ -187,6 +187,7 @@ class DiskWipe:
187
187
  line += f' [P]ass={self.opts.passes}'
188
188
  # Show verification percentage spinner with key
189
189
  line += f' [V]pct={self.opts.verify_pct}%'
190
+ line += f' [p]ort'
190
191
  line += ' '
191
192
  if self.opts.dry_run:
192
193
  line += ' DRY-RUN'
@@ -295,6 +296,7 @@ class DiskWipe:
295
296
  spin = self.spin = OptionSpinner(stack=self.stack)
296
297
  spin.default_obj = self.opts
297
298
  spin.add_key('dense', 'D - dense/spaced view', vals=[False, True])
299
+ spin.add_key('port_serial', 'p - disk port info', vals=[False, True])
298
300
  spin.add_key('slowdown_stop', 'L - stop if disk slows Nx', vals=[16, 64, 256, 0, 4])
299
301
  spin.add_key('stall_timeout', 'T - stall timeout (sec)', vals=[60, 120, 300, 600, 0,])
300
302
  spin.add_key('verify_pct', 'V - verification %', vals=[0, 2, 5, 10, 25, 50, 100])
@@ -372,6 +374,11 @@ class DiskWipeScreen(Screen):
372
374
  class MainScreen(DiskWipeScreen):
373
375
  """Main device list screen"""
374
376
 
377
+ def _port_serial_line(self, port, serial):
378
+ wids = self.app.wids
379
+ wid = wids.state if wids else 5
380
+ sep = ' '
381
+ return f'{"":>{wid}}{sep}│ └────── {port:<12} {serial}'
375
382
 
376
383
  def draw_screen(self):
377
384
  """Draw the main device list"""
@@ -612,6 +619,9 @@ class MainScreen(DiskWipeScreen):
612
619
  ctx = Context(genre='disk' if partition.parent is None else 'partition',
613
620
  partition=partition)
614
621
  app.win.add_body(partition.line, attr=attr, context=ctx)
622
+ if partition.parent is None and app.opts.port_serial:
623
+ line = self._port_serial_line(partition.port, partition.serial)
624
+ app.win.add_body(line, attr=attr, context=Context(genre='DECOR'))
615
625
 
616
626
  # Show inline confirmation prompt if this is the partition being confirmed
617
627
  if app.confirmation.active and app.confirmation.partition_name == partition.name:
dwipe/PersistentState.py CHANGED
@@ -25,12 +25,13 @@ class PersistentState:
25
25
  self.state = {
26
26
  'theme': 'default',
27
27
  'wipe_mode': 'Zero', # 'Rand' or 'Zero' or +V
28
- 'passes': 1, # 1, 2, or 4 wipe passes
28
+ 'passes': 1, # 1, 2, or 4 wipe pass
29
29
  'confirmation': 'YES', # 'Y', 'y', 'YES', 'yes', 'device'
30
30
  'verify_pct': 2, # 0, 2, 5, 10, 25, 50, 100
31
31
  'dense': False, # True = compact view, False = blank lines between disks
32
32
  'slowdown_stop': 16,
33
33
  'stall_timeout': 60,
34
+ 'port_serial': False,
34
35
  'devices': {} # device_id -> {locked, last_seen, last_name, size_bytes}
35
36
  }
36
37
  self.dirty = False
@@ -61,15 +62,6 @@ class PersistentState:
61
62
  try:
62
63
  with open(self.config_path, 'r', encoding='utf-8') as f:
63
64
  loaded = json.load(f)
64
- # Migrate old mode values to new format
65
- # if 'mode' in loaded:
66
- # old_mode = loaded['mode']
67
- # if old_mode in ('random', 'Random', 'RANDOM'):
68
- # loaded['mode'] = 'Rand'
69
- # self.dirty = True # Save migrated value
70
- # elif old_mode in ('zero', 'zeros', 'Zero', 'ZERO'):
71
- # loaded['mode'] = 'Zero'
72
- # self.dirty = True # Save migrated value
73
65
  self.state.update(loaded)
74
66
  except (json.JSONDecodeError, IOError) as e:
75
67
  print(f'Warning: Could not load state from {self.config_path}: {e}')
dwipe/ToolManager.py ADDED
@@ -0,0 +1,637 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Hardware Secure Erase Module for dwipe
4
+ Provides pre-checks, execution, monitoring, and fallback for hardware-level wipes
5
+ """
6
+
7
+ import subprocess
8
+ import shutil
9
+ import os
10
+ import time
11
+ import threading
12
+ import sys
13
+ from typing import Dict, List, Optional, Tuple, Callable
14
+ from enum import Enum
15
+ from dataclasses import dataclass
16
+
17
+ # ============================================================================
18
+ # Part 1: Tool Manager (Dependency Management)
19
+ # ============================================================================
20
+
21
+ class ToolManager:
22
+ """Manages tool dependencies (hdparm, nvme-cli)"""
23
+
24
+ TOOL_PACKAGES = {
25
+ 'hdparm': {
26
+ 'apt': ['hdparm'],
27
+ 'dnf': ['hdparm'],
28
+ 'yum': ['hdparm'],
29
+ 'pacman': ['hdparm'],
30
+ 'zypper': ['hdparm'],
31
+ 'apk': ['hdparm'],
32
+ 'brew': ['hdparm'],
33
+ },
34
+ 'nvme': {
35
+ 'apt': ['nvme-cli'],
36
+ 'dnf': ['nvme-cli'],
37
+ 'yum': ['nvme-cli'],
38
+ 'pacman': ['nvme-cli'],
39
+ 'zypper': ['nvme-cli'],
40
+ 'apk': ['nvme-cli'],
41
+ 'brew': ['nvme-cli'],
42
+ }
43
+ }
44
+
45
+ def __init__(self, auto_install: bool = False, verbose: bool = False):
46
+ self.auto_install = auto_install
47
+ self.verbose = verbose
48
+ self.package_manager = self._detect_package_manager()
49
+
50
+ def _detect_package_manager(self) -> Optional[str]:
51
+ package_managers = {
52
+ 'apt': ['apt-get', 'apt'],
53
+ 'dnf': ['dnf'],
54
+ 'yum': ['yum'],
55
+ 'pacman': ['pacman'],
56
+ 'zypper': ['zypper'],
57
+ 'apk': ['apk'],
58
+ 'brew': ['brew'],
59
+ }
60
+
61
+ for pm, binaries in package_managers.items():
62
+ for binary in binaries:
63
+ if shutil.which(binary):
64
+ return pm
65
+ return None
66
+
67
+ def tool_available(self, tool_name: str) -> bool:
68
+ return shutil.which(tool_name) is not None
69
+
70
+ def ensure_tool(self, tool_name: str, critical: bool = True) -> bool:
71
+ if self.tool_available(tool_name):
72
+ return True
73
+
74
+ if self.auto_install and self._install_tool(tool_name):
75
+ return True
76
+
77
+ if critical:
78
+ print(f"ERROR: Required tool '{tool_name}' not found")
79
+ packages = self.TOOL_PACKAGES.get(tool_name, {}).get(self.package_manager, [])
80
+ if packages:
81
+ print(f"Install with: sudo {self.package_manager} install {packages[0]}")
82
+ return False
83
+
84
+ def _install_tool(self, tool_name: str) -> bool:
85
+ """Install tool using package manager"""
86
+ # Simplified - use the installation logic from earlier if needed
87
+ return False # Placeholder
88
+
89
+ def get_tool_path(self, tool_name: str) -> Optional[str]:
90
+ return shutil.which(tool_name)
91
+
92
+ # ============================================================================
93
+ # Part 2: Drive Pre-Checks
94
+ # ============================================================================
95
+
96
+ class EraseStatus(Enum):
97
+ NOT_STARTED = "not_started"
98
+ STARTING = "starting"
99
+ IN_PROGRESS = "in_progress"
100
+ COMPLETE = "complete"
101
+ FAILED = "failed"
102
+ UNKNOWN = "unknown"
103
+
104
+ @dataclass
105
+ class PreCheckResult:
106
+ compatible: bool = False
107
+ tool: Optional[str] = None
108
+ frozen: bool = False
109
+ locked: bool = False
110
+ enhanced_supported: bool = False
111
+ issues: List[str] = None
112
+ recommendation: Optional[str] = None
113
+
114
+ def __post_init__(self):
115
+ if self.issues is None:
116
+ self.issues = []
117
+
118
+ class DrivePreChecker:
119
+ """Pre-check drive before attempting secure erase"""
120
+
121
+ def __init__(self, timeout: int = 10):
122
+ self.timeout = timeout
123
+
124
+ def is_usb_attached(self, device: str) -> bool:
125
+ """Check if device is USB-attached"""
126
+ dev_name = os.path.basename(device)
127
+
128
+ # Check via sysfs
129
+ sys_path = f'/sys/block/{dev_name}'
130
+ if os.path.exists(sys_path):
131
+ try:
132
+ # Check if in USB hierarchy
133
+ real_path = os.path.realpath(sys_path)
134
+ if 'usb' in real_path.lower():
135
+ return True
136
+
137
+ # Check via udev
138
+ udev_info = subprocess.run(
139
+ ['udevadm', 'info', '-q', 'property', '-n', device],
140
+ capture_output=True,
141
+ text=True,
142
+ timeout=5
143
+ )
144
+ if udev_info.returncode == 0 and 'ID_BUS=usb' in udev_info.stdout:
145
+ return True
146
+ except:
147
+ pass
148
+
149
+ return False
150
+
151
+ def check_nvme_drive(self, device: str) -> PreCheckResult:
152
+ """Check if NVMe secure erase will likely work"""
153
+ result = PreCheckResult(tool='nvme')
154
+
155
+ try:
156
+ # Check if device exists
157
+ if not os.path.exists(device):
158
+ result.issues.append(f"Device {device} does not exist")
159
+ return result
160
+
161
+ # Check USB attachment
162
+ if self.is_usb_attached(device):
163
+ result.issues.append("NVMe is USB-attached - hardware erase unreliable")
164
+ result.recommendation = "Use software wipe"
165
+ return result
166
+
167
+ # Check if NVMe device responds
168
+ id_ctrl = subprocess.run(
169
+ ['nvme', 'id-ctrl', device],
170
+ capture_output=True,
171
+ text=True,
172
+ timeout=self.timeout
173
+ )
174
+
175
+ if id_ctrl.returncode != 0:
176
+ result.issues.append(f"Not an NVMe device: {id_ctrl.stderr}")
177
+ return result
178
+
179
+ # Check format support
180
+ if 'Format NVM' not in id_ctrl.stdout:
181
+ result.issues.append("Drive doesn't support Format NVM command")
182
+
183
+ # Check for write protection
184
+ id_ns = subprocess.run(
185
+ ['nvme', 'id-ns', device],
186
+ capture_output=True,
187
+ text=True,
188
+ timeout=self.timeout
189
+ )
190
+
191
+ if id_ns.returncode == 0 and 'Write Protected' in id_ns.stdout:
192
+ result.issues.append("Namespace is write protected")
193
+
194
+ result.compatible = len(result.issues) == 0
195
+ result.recommendation = "Proceed with hardware erase" if result.compatible else "Use software wipe"
196
+
197
+ except subprocess.TimeoutExpired:
198
+ result.issues.append(f"Command timed out after {self.timeout}s")
199
+ except Exception as e:
200
+ result.issues.append(f"Unexpected error: {e}")
201
+
202
+ return result
203
+
204
+ def check_ata_drive(self, device: str) -> PreCheckResult:
205
+ """Check if ATA secure erase will likely work"""
206
+ result = PreCheckResult(tool='hdparm')
207
+
208
+ try:
209
+ if not os.path.exists(device):
210
+ result.issues.append(f"Device {device} does not exist")
211
+ return result
212
+
213
+ # Check USB attachment
214
+ if self.is_usb_attached(device):
215
+ result.issues.append("Drive is USB-attached - hardware erase unreliable")
216
+ result.recommendation = "Use software wipe"
217
+ return result
218
+
219
+ # Get drive info
220
+ info = subprocess.run(
221
+ ['hdparm', '-I', device],
222
+ capture_output=True,
223
+ text=True,
224
+ timeout=self.timeout
225
+ )
226
+
227
+ if info.returncode != 0:
228
+ result.issues.append(f"Drive not responsive: {info.stderr}")
229
+ return result
230
+
231
+ output = info.stdout
232
+
233
+ # Check if frozen
234
+ if 'frozen' in output.lower():
235
+ result.frozen = True
236
+ result.issues.append("Drive is FROZEN - will hang on erase")
237
+
238
+ # Check if locked/enabled
239
+ if 'enabled' in output and 'not' not in output:
240
+ result.locked = True
241
+ result.issues.append("Security is ENABLED - needs password")
242
+
243
+ # Check enhanced erase support
244
+ if 'supported: enhanced erase' in output:
245
+ result.enhanced_supported = True
246
+
247
+ # Check ATA device and erase support
248
+ if 'ATA' not in output and 'SATA' not in output:
249
+ result.issues.append("Not an ATA/SATA device")
250
+
251
+ if 'SECURITY ERASE UNIT' not in output:
252
+ result.issues.append("Drive doesn't support SECURITY ERASE UNIT")
253
+
254
+ result.compatible = len(result.issues) == 0
255
+
256
+ if result.compatible:
257
+ result.recommendation = "Proceed with hardware erase"
258
+ elif result.frozen:
259
+ result.recommendation = "Thaw drive first or use software wipe"
260
+ elif result.locked:
261
+ result.recommendation = "Disable security first or use software wipe"
262
+ else:
263
+ result.recommendation = "Use software wipe"
264
+
265
+ except subprocess.TimeoutExpired:
266
+ result.issues.append(f"Command timed out after {self.timeout}s")
267
+ except Exception as e:
268
+ result.issues.append(f"Unexpected error: {e}")
269
+
270
+ return result
271
+
272
+ def can_use_hardware_erase(self, device: str) -> PreCheckResult:
273
+ """
274
+ Determine if hardware erase will work.
275
+ Returns comprehensive pre-check result.
276
+ """
277
+ if not os.path.exists(device):
278
+ return PreCheckResult(issues=[f"Device {device} does not exist"])
279
+
280
+ if 'nvme' in device:
281
+ return self.check_nvme_drive(device)
282
+ elif device.startswith('/dev/sd'):
283
+ return self.check_ata_drive(device)
284
+ else:
285
+ return PreCheckResult(issues=[f"Unsupported device type: {device}"])
286
+
287
+ # ============================================================================
288
+ # Part 3: Drive Eraser with Monitoring
289
+ # ============================================================================
290
+
291
+ class DriveEraser:
292
+ """Execute and monitor hardware secure erase"""
293
+
294
+ def __init__(self, progress_callback: Optional[Callable] = None):
295
+ self.status = EraseStatus.NOT_STARTED
296
+ self.start_time = None
297
+ self.progress_callback = progress_callback
298
+ self.monitor_thread = None
299
+ self.current_process = None
300
+
301
+ def start_nvme_erase(self, device: str) -> bool:
302
+ """Start NVMe secure erase (non-blocking)"""
303
+ try:
304
+ self.current_process = subprocess.Popen(
305
+ ['nvme', 'format', device, '--ses=1'],
306
+ stdout=subprocess.PIPE,
307
+ stderr=subprocess.PIPE,
308
+ text=True
309
+ )
310
+
311
+ self.status = EraseStatus.STARTING
312
+ self.start_time = time.time()
313
+ self._start_monitoring(device, 'nvme')
314
+ return True
315
+
316
+ except Exception as e:
317
+ print(f"Failed to start NVMe erase: {e}")
318
+ self.status = EraseStatus.FAILED
319
+ return False
320
+
321
+ def start_ata_erase(self, device: str, enhanced: bool = True) -> bool:
322
+ """Start ATA secure erase (non-blocking)"""
323
+ try:
324
+ # Build command
325
+ cmd = ['hdparm', '--user-master', 'u']
326
+ if enhanced:
327
+ cmd.extend(['--security-erase-enhanced', 'NULL'])
328
+ else:
329
+ cmd.extend(['--security-erase', 'NULL'])
330
+ cmd.append(device)
331
+
332
+ self.current_process = subprocess.Popen(
333
+ cmd,
334
+ stdout=subprocess.PIPE,
335
+ stderr=subprocess.PIPE,
336
+ text=True
337
+ )
338
+
339
+ self.status = EraseStatus.STARTING
340
+ self.start_time = time.time()
341
+ self._start_monitoring(device, 'ata')
342
+ return True
343
+
344
+ except Exception as e:
345
+ print(f"Failed to start ATA erase: {e}")
346
+ self.status = EraseStatus.FAILED
347
+ return False
348
+
349
+ def _start_monitoring(self, device: str, drive_type: str):
350
+ """Start background monitoring thread"""
351
+ def monitor():
352
+ time.sleep(3) # Let command start
353
+ self.status = EraseStatus.IN_PROGRESS
354
+
355
+ check_interval = 5
356
+ max_checks = 7200 # 10 hours max
357
+
358
+ for _ in range(max_checks):
359
+ # Check if process completed
360
+ if self.current_process and self.current_process.poll() is not None:
361
+ if self.current_process.returncode == 0:
362
+ self.status = EraseStatus.COMPLETE
363
+ else:
364
+ self.status = EraseStatus.FAILED
365
+ break
366
+
367
+ # Update progress callback
368
+ if self.progress_callback:
369
+ elapsed = time.time() - self.start_time
370
+ progress = self._estimate_progress(elapsed, drive_type)
371
+ self.progress_callback(progress, elapsed, self.status)
372
+
373
+ time.sleep(check_interval)
374
+ else:
375
+ self.status = EraseStatus.FAILED
376
+
377
+ self.monitor_thread = threading.Thread(target=monitor, daemon=True)
378
+ self.monitor_thread.start()
379
+
380
+ def _estimate_progress(self, elapsed_seconds: float, drive_type: str) -> float:
381
+ """Estimate fake progress based on typical times"""
382
+ if drive_type == 'nvme':
383
+ progress = min(1.0, elapsed_seconds / 30)
384
+ elif drive_type == 'ata':
385
+ # Very rough estimate - would need drive size for better guess
386
+ progress = min(1.0, elapsed_seconds / 3600)
387
+ else:
388
+ progress = 0.0
389
+
390
+ return progress * 100
391
+
392
+ def get_status(self) -> Dict:
393
+ """Get current status info"""
394
+ elapsed = time.time() - self.start_time if self.start_time else 0
395
+
396
+ return {
397
+ 'status': self.status.value,
398
+ 'elapsed_seconds': elapsed,
399
+ 'monitor_alive': self.monitor_thread and self.monitor_thread.is_alive(),
400
+ 'process_active': self.current_process and self.current_process.poll() is None
401
+ }
402
+
403
+ def wait_for_completion(self, timeout: Optional[float] = None) -> bool:
404
+ """Wait for erase to complete"""
405
+ if not self.current_process:
406
+ return False
407
+
408
+ try:
409
+ return_code = self.current_process.wait(timeout=timeout)
410
+ return return_code == 0
411
+ except subprocess.TimeoutExpired:
412
+ return False
413
+
414
+ # ============================================================================
415
+ # Part 4: Main Wipe Controller (Integration Point)
416
+ # ============================================================================
417
+
418
+ class HardwareWipeController:
419
+ """
420
+ Main controller for hardware wiping.
421
+ This is what you'd integrate into dwipe.
422
+ """
423
+
424
+ def __init__(self, auto_install_tools: bool = False, verbose: bool = False):
425
+ self.tool_mgr = ToolManager(auto_install=auto_install_tools, verbose=verbose)
426
+ self.pre_checker = DrivePreChecker(timeout=15)
427
+ self.eraser = None
428
+ self.verbose = verbose
429
+
430
+ def _log(self, message: str):
431
+ if self.verbose:
432
+ print(f"[HardwareWipe] {message}")
433
+
434
+ def prepare(self) -> bool:
435
+ """Ensure required tools are available"""
436
+ if not self.tool_mgr.ensure_tool('hdparm', critical=True):
437
+ return False
438
+ if not self.tool_mgr.ensure_tool('nvme', critical=True):
439
+ return False
440
+ return True
441
+
442
+ def pre_check(self, device: str) -> PreCheckResult:
443
+ """Perform comprehensive pre-check"""
444
+ self._log(f"Pre-checking {device}...")
445
+ result = self.pre_checker.can_use_hardware_erase(device)
446
+
447
+ if self.verbose:
448
+ print(f"Pre-check for {device}:")
449
+ print(f" Compatible: {result.compatible}")
450
+ print(f" Tool: {result.tool}")
451
+ if result.issues:
452
+ print(f" Issues: {', '.join(result.issues)}")
453
+ if result.recommendation:
454
+ print(f" Recommendation: {result.recommendation}")
455
+
456
+ return result
457
+
458
+ def wipe(self, device: str, fallback_callback: Optional[Callable] = None) -> bool:
459
+ """
460
+ Execute hardware wipe with automatic fallback.
461
+
462
+ Args:
463
+ device: Device path (/dev/sda, /dev/nvme0n1, etc.)
464
+ fallback_callback: Function to call if hardware wipe fails
465
+ Should accept device path and return bool
466
+
467
+ Returns:
468
+ True if wipe succeeded (hardware or software), False otherwise
469
+ """
470
+ if not self.prepare():
471
+ print("Required tools not available")
472
+ return False
473
+
474
+ # Step 1: Pre-check
475
+ pre_check = self.pre_check(device)
476
+
477
+ if not pre_check.compatible:
478
+ print(f"Hardware erase not compatible for {device}:")
479
+ for issue in pre_check.issues:
480
+ print(f" - {issue}")
481
+
482
+ if fallback_callback:
483
+ self._log("Falling back to software wipe...")
484
+ return fallback_callback(device)
485
+ return False
486
+
487
+ # Step 2: Show user what to expect
488
+ tool_name = pre_check.tool
489
+ print(f"Using {tool_name} for hardware secure erase...")
490
+ print("Note: Drive erases in firmware - tool will exit immediately.")
491
+
492
+ if tool_name == 'nvme':
493
+ print("Expected time: 2-10 seconds")
494
+ elif tool_name == 'hdparm' and pre_check.enhanced_supported:
495
+ print("Expected time: 10-60 seconds (enhanced erase)")
496
+ elif tool_name == 'hdparm':
497
+ print("Expected time: 1-3 hours per TB (normal erase)")
498
+
499
+ # Step 3: Start erase
500
+ self.eraser = DriveEraser(progress_callback=self._progress_update)
501
+
502
+ try:
503
+ if tool_name == 'nvme':
504
+ success = self.eraser.start_nvme_erase(device)
505
+ else: # hdparm
506
+ enhanced = pre_check.enhanced_supported
507
+ success = self.eraser.start_ata_erase(device, enhanced)
508
+
509
+ if not success:
510
+ raise RuntimeError("Failed to start erase")
511
+
512
+ # Step 4: Monitor with timeout
513
+ timeout = self._get_timeout(tool_name, device)
514
+ print(f"Waiting up to {timeout//60} minutes for completion...")
515
+
516
+ # Simple spinner while waiting
517
+ spinner = ['|', '/', '-', '\\']
518
+ i = 0
519
+
520
+ while True:
521
+ status = self.eraser.get_status()
522
+
523
+ if status['status'] == EraseStatus.COMPLETE.value:
524
+ print(f"\nHardware secure erase completed successfully!")
525
+ return True
526
+
527
+ elif status['status'] == EraseStatus.FAILED.value:
528
+ print(f"\nHardware secure erase failed")
529
+ break
530
+
531
+ # Show spinner and elapsed time
532
+ elapsed = status['elapsed_seconds']
533
+ print(f"\r{spinner[i % 4]} Erasing... {int(elapsed)}s elapsed", end='')
534
+ i += 1
535
+
536
+ # Check timeout
537
+ if elapsed > timeout:
538
+ print(f"\nTimeout after {timeout} seconds")
539
+ break
540
+
541
+ time.sleep(0.5)
542
+
543
+ # If we get here, hardware failed
544
+ if fallback_callback:
545
+ print("Falling back to software wipe...")
546
+ return fallback_callback(device)
547
+
548
+ return False
549
+
550
+ except Exception as e:
551
+ print(f"Error during hardware erase: {e}")
552
+ if fallback_callback:
553
+ return fallback_callback(device)
554
+ return False
555
+
556
+ def _progress_update(self, progress: float, elapsed: float, status: EraseStatus):
557
+ """Callback for progress updates"""
558
+ if self.verbose:
559
+ print(f"[Progress] {progress:.1f}% - {elapsed:.0f}s - {status.value}")
560
+
561
+ def _get_timeout(self, tool: str, device: str) -> int:
562
+ """Get appropriate timeout based on drive type"""
563
+ if tool == 'nvme':
564
+ return 30 # 30 seconds for NVMe
565
+ elif tool == 'hdparm':
566
+ # Try to get drive size for better timeout
567
+ try:
568
+ size_gb = self._get_drive_size_gb(device)
569
+ # 2 hours per TB, minimum 30 minutes
570
+ hours = max(0.5, (size_gb / 1024) * 2)
571
+ return int(hours * 3600)
572
+ except:
573
+ return 7200 # 2 hours default
574
+ return 3600 # 1 hour default
575
+
576
+ def _get_drive_size_gb(self, device: str) -> float:
577
+ """Get drive size in GB"""
578
+ try:
579
+ # Use blockdev to get size
580
+ result = subprocess.run(
581
+ ['blockdev', '--getsize64', device],
582
+ capture_output=True,
583
+ text=True,
584
+ timeout=5
585
+ )
586
+ if result.returncode == 0:
587
+ size_bytes = int(result.stdout.strip())
588
+ return size_bytes / (1024**3) # Convert to GB
589
+ except:
590
+ pass
591
+ return 500 # Default guess
592
+
593
+ # ============================================================================
594
+ # Part 5: Example Usage & Integration Helper
595
+ # ============================================================================
596
+
597
+ def example_software_wipe(device: str) -> bool:
598
+ """Example fallback function for software wipe"""
599
+ print(f"[Software] Would wipe {device} with dd/scrub/etc.")
600
+ # Implement your existing software wipe here
601
+ return True
602
+
603
+ def main():
604
+ """Example standalone usage"""
605
+ import argparse
606
+
607
+ parser = argparse.ArgumentParser(description='Hardware Secure Erase Test')
608
+ parser.add_argument('device', help='Device to wipe (e.g., /dev/sda)')
609
+ parser.add_argument('--auto-install', action='store_true',
610
+ help='Automatically install missing tools')
611
+ parser.add_argument('--verbose', '-v', action='store_true',
612
+ help='Verbose output')
613
+ parser.add_argument('--no-fallback', action='store_true',
614
+ help='Don\'t fall back to software wipe')
615
+ args = parser.parse_args()
616
+
617
+ # Create controller
618
+ controller = HardwareWipeController(
619
+ auto_install_tools=args.auto_install,
620
+ verbose=args.verbose
621
+ )
622
+
623
+ # Define fallback
624
+ fallback = None if args.no_fallback else example_software_wipe
625
+
626
+ # Execute wipe
627
+ success = controller.wipe(args.device, fallback_callback=fallback)
628
+
629
+ if success:
630
+ print(f"\n✓ Wipe completed successfully")
631
+ return 0
632
+ else:
633
+ print(f"\n✗ Wipe failed")
634
+ return 1
635
+
636
+ if __name__ == '__main__':
637
+ sys.exit(main())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dwipe
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: A tool to wipe disks and partitions for Linux
5
5
  Keywords: disk,partition,wipe,clean,scrub
6
6
  Author-email: Joe Defen <joedef@google.com>
@@ -10,7 +10,7 @@ Classifier: Programming Language :: Python :: 3
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: POSIX :: Linux
12
12
  License-File: LICENSE
13
- Requires-Dist: console-window >= 1.0.0
13
+ Requires-Dist: console-window == 1.3.2
14
14
  Project-URL: Bug Tracker, https://github.com/joedefen/dwipe/issues
15
15
  Project-URL: Homepage, https://github.com/joedefen/dwipe
16
16
 
@@ -60,6 +60,9 @@ Project-URL: Homepage, https://github.com/joedefen/dwipe
60
60
  * **Direct I/O to Disk** - Wiping is done with direct I/O which is fast and avoid polluting your page cache. Writer threads are given lower than normal I/O priority to play nice with other apps. This makes stopping jobs fast and certain.
61
61
  * **Improved Handling of Bad Disks.** Now detects (sometimes corrects) write failures, slowdowns, excessive no progress, and reports/aborts hopeless or hopelessly slow wipes.
62
62
 
63
+ ## **V2.x Features**
64
+ Features added since V2 deployed (may not be in latest demo):
65
+ * **Port and Serial number**. Press `p` to toggle whether port and serial number is show; it adds another line per disk and you may want to use it selectively.
63
66
  ## Requirements
64
67
  - **Linux operating system** (uses `/dev/`, `/sys/`, `/proc/` interfaces)
65
68
  - **Python 3.8 or higher**
@@ -0,0 +1,14 @@
1
+ dwipe/DeviceInfo.py,sha256=7ZgtLDT1z5QwMWVwEb9Q3-wonvuPyAWgl7Xl5o14XbY,25777
2
+ dwipe/DiskWipe.py,sha256=SVfcGDMZccGBocpS5U8JXI-Y2UzRix3jAzzaYPVRCDc,40161
3
+ dwipe/PersistentState.py,sha256=FZydQs_-VYiq-Gw0hvcWPeuWOU5VFgI-FvrUsbT92RQ,6339
4
+ dwipe/ToolManager.py,sha256=EpG_588Q76IzFfjrTM5LSnhTUC1CoBeCUdqtR0duliY,23371
5
+ dwipe/Utils.py,sha256=Cuq8Usamrq1DWUk8EtjTuD6lSLXYGY0x-pDcoLJBR8M,7714
6
+ dwipe/WipeJob.py,sha256=bjv_hVuH5DBDgY5-y3Mv11URhsBu1t1iu8lx76dmAhE,54486
7
+ dwipe/WipeJobFuture.py,sha256=urkrASHtqELsKQ5c7OMc_LxpgIiYAIOeb1ZixQYmp74,8746
8
+ dwipe/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ dwipe/main.py,sha256=Zyw9LFBGqWYxga1H9qTdQGHLyCsxZUg3tm_Ylj20FG4,1841
10
+ dwipe-2.0.1.dist-info/entry_points.txt,sha256=SZHFezmse2c-jxG-BJ0TXy_TZ8vVFf0lPJWs0cdxz6Y,41
11
+ dwipe-2.0.1.dist-info/licenses/LICENSE,sha256=qB9OdnyyF6WYHiEIXVm0rOSdcf8e2ctorrtWs6CC5lU,1062
12
+ dwipe-2.0.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
13
+ dwipe-2.0.1.dist-info/METADATA,sha256=kcykkG3kr9kyZ0T6nUN_dGCxbC3E2fh26HovbCnqGD4,21957
14
+ dwipe-2.0.1.dist-info/RECORD,,
@@ -1,13 +0,0 @@
1
- dwipe/DeviceInfo.py,sha256=MKoxZ5iDzKdJwIRWn2Sjpdl28tbqsW5_O3SRctpYXRE,21395
2
- dwipe/DiskWipe.py,sha256=nmnK4gGxvzvKV5Z4i_Ef4eENyTt-Ld0G_mfk-0dPHzg,39611
3
- dwipe/PersistentState.py,sha256=-2jcq5lOo6wFMUxCYR5Vxwgs4gk8KXasH-RVNSCymPY,6847
4
- dwipe/Utils.py,sha256=Cuq8Usamrq1DWUk8EtjTuD6lSLXYGY0x-pDcoLJBR8M,7714
5
- dwipe/WipeJob.py,sha256=bjv_hVuH5DBDgY5-y3Mv11URhsBu1t1iu8lx76dmAhE,54486
6
- dwipe/WipeJobFuture.py,sha256=urkrASHtqELsKQ5c7OMc_LxpgIiYAIOeb1ZixQYmp74,8746
7
- dwipe/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- dwipe/main.py,sha256=Zyw9LFBGqWYxga1H9qTdQGHLyCsxZUg3tm_Ylj20FG4,1841
9
- dwipe-2.0.0.dist-info/entry_points.txt,sha256=SZHFezmse2c-jxG-BJ0TXy_TZ8vVFf0lPJWs0cdxz6Y,41
10
- dwipe-2.0.0.dist-info/licenses/LICENSE,sha256=qB9OdnyyF6WYHiEIXVm0rOSdcf8e2ctorrtWs6CC5lU,1062
11
- dwipe-2.0.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
12
- dwipe-2.0.0.dist-info/METADATA,sha256=PIIIZRcbEsmmd2c3noJyGVACbQyqIgUZZiv3IW618ko,21713
13
- dwipe-2.0.0.dist-info/RECORD,,
File without changes