dwipe 2.0.0__py3-none-any.whl → 2.0.2__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/ToolManager.py ADDED
@@ -0,0 +1,618 @@
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
+ # ============================================================================
22
+ # Part 2: Drive Pre-Checks
23
+ # ============================================================================
24
+
25
+ class EraseStatus(Enum):
26
+ NOT_STARTED = "not_started"
27
+ STARTING = "starting"
28
+ IN_PROGRESS = "in_progress"
29
+ COMPLETE = "complete"
30
+ FAILED = "failed"
31
+ UNKNOWN = "unknown"
32
+
33
+ @dataclass
34
+ class OLD-PreCheckResult:
35
+ compatible: bool = False
36
+ tool: Optional[str] = None
37
+ frozen: bool = False
38
+ locked: bool = False
39
+ enhanced_supported: bool = False
40
+ issues: List[str] = None
41
+ recommendation: Optional[str] = None
42
+
43
+ @dataclass
44
+ class PreCheckResult:
45
+ # compatible: bool = False
46
+ # tool: Optional[str] = None
47
+ # frozen: bool = False
48
+ # locked: bool = False
49
+ # enhanced_supported: bool = False
50
+ issues: List[str] = None # list of "why not" ... any set, no wipe
51
+ # recommendation: Optional[str] = None
52
+ modes = {} # dict of descr/how 'Cropto': '--wipe=crypto'
53
+
54
+ def __post_init__(self):
55
+ if self.issues is None:
56
+ self.issues = []
57
+
58
+ class DrivePreChecker:
59
+ """Pre-check drive before attempting secure erase"""
60
+
61
+ def __init__(self, timeout: int = 10):
62
+ self.timeout = timeout
63
+
64
+ def is_usb_attached(self, device: str) -> bool:
65
+ """Check if device is USB-attached"""
66
+ dev_name = os.path.basename(device)
67
+
68
+ # Check via sysfs
69
+ sys_path = f'/sys/block/{dev_name}'
70
+ if os.path.exists(sys_path):
71
+ try:
72
+ # Check if in USB hierarchy
73
+ real_path = os.path.realpath(sys_path)
74
+ if 'usb' in real_path.lower():
75
+ return True
76
+
77
+ # Check via udev
78
+ udev_info = subprocess.run(
79
+ ['udevadm', 'info', '-q', 'property', '-n', device],
80
+ capture_output=True,
81
+ text=True,
82
+ timeout=5
83
+ )
84
+ if udev_info.returncode == 0 and 'ID_BUS=usb' in udev_info.stdout:
85
+ return True
86
+ except:
87
+ pass
88
+
89
+ return False
90
+
91
+ def check_nvme_drive(self, device: str) -> PreCheckResult:
92
+ """Probes NVMe and returns specific command flags for available wipe modes"""
93
+ result = PreCheckResult()
94
+ result.modes = {}
95
+
96
+ try:
97
+ # Get controller capabilities in JSON for easy parsing
98
+ id_ctrl = subprocess.run(
99
+ ['nvme', 'id-ctrl', device, '-o', 'json'],
100
+ capture_output=True, text=True, timeout=self.timeout
101
+ )
102
+
103
+ if id_ctrl.returncode != 0:
104
+ result.issues.append("NVMe controller unresponsive")
105
+ return result
106
+
107
+ import json
108
+ data = json.loads(id_ctrl.stdout)
109
+
110
+ # 1. Check for Sanitize Capabilities (The most modern/safe method)
111
+ # Bit 1: Block, Bit 2: Crypto, Bit 3: Overwrite
112
+ sanicap = data.get('sanicap', 0)
113
+ if sanicap > 0:
114
+ # We use OrderedDict or similar to put the 'best' options first
115
+ if sanicap & 0x04: # Crypto Erase
116
+ result.modes['Sanitize-Crypto'] = 'sanitize --action=0x04'
117
+ if sanicap & 0x02: # Block Erase (Physical)
118
+ result.modes['Sanitize-Block'] = 'sanitize --action=0x02'
119
+ if sanicap & 0x08: # Overwrite
120
+ result.modes['Sanitize-Overwrite'] = 'sanitize --action=0x03'
121
+
122
+ # 2. Check for Legacy Format Capabilities
123
+ # Bit 1: Crypto, Bit 2: User Data Erase
124
+ fna = data.get('fna', 0)
125
+ if 'Format NVM' in id_ctrl.stdout:
126
+ # Check if Crypto Erase is supported via Format
127
+ if (fna >> 2) & 0x1:
128
+ result.modes['Format-Crypto'] = 'format --ses=2'
129
+ # Standard User Data Erase
130
+ result.modes['Format-Erase'] = 'format --ses=1'
131
+
132
+ # Final Validation
133
+ if not result.modes:
134
+ result.issues.append("No HW wipe modes (Sanitize/Format) supported")
135
+
136
+ except Exception as e:
137
+ result.issues.append(f"Probe Error: {str(e)}")
138
+
139
+ return result
140
+
141
+ def check_ata_drive(self, device: str) -> PreCheckResult:
142
+ """Probes SATA/ATA and returns hdparm flags or specific blocking reasons
143
+ + Why the "NULL" password? In the modes dictionary above, we use NULL.
144
+ - To perform an ATA Secure Erase, you have to set a temporary password first,
145
+ then immediately issue the erase command with that same password.
146
+ - Most tools (and hdparm itself) use NULL or a simple string like p
147
+ as a throwaway.
148
+ - Note: If the dwipe app crashes after setting the password but before the
149
+ erase finishes, the drive will stay locked. On the next run, your enabled
150
+ check (Step 3) will catch this.
151
+ + Handling "Frozen" in the UI
152
+ -the "Frozen" issue is the one that will frustrate users most.
153
+ -The "Short Crisp Reason": Drive is FROZEN.
154
+ - The Fix: To unfreeze, try suspending (sleeping) and waking the computer,
155
+ or re-plugging the drive's power cable."
156
+
157
+ + Now, dwipe builds that list:
158
+ It calls can_use_hardware_erase().
159
+ It looks at result.issues. If empty, the [f]:irmW key is active.
160
+ """
161
+ result = PreCheckResult()
162
+ result.modes = {}
163
+
164
+ try:
165
+ # Get drive info via hdparm
166
+ info = subprocess.run(
167
+ ['hdparm', '-I', device],
168
+ capture_output=True, text=True, timeout=self.timeout
169
+ )
170
+
171
+ if info.returncode != 0:
172
+ result.issues.append("Drive not responsive to hdparm")
173
+ return result
174
+
175
+ out = info.stdout.lower()
176
+
177
+ # 1. Check if the drive even supports Security Erase
178
+ if "security erase unit" not in out:
179
+ result.issues.append("Drive does not support ATA Security Erase")
180
+ return result
181
+
182
+ # 2. Check for "Frozen" state (The most common blocker)
183
+ # A frozen drive rejects security commands until a power cycle.
184
+ if "frozen" in out and "not frozen" not in out:
185
+ result.issues.append("Drive is FROZEN (BIOS/OS lock)")
186
+ # You might want to keep this in issues so user can't select it,
187
+ # or move it to a 'warning' if you want to allow them to try anyway.
188
+
189
+ # 3. Check if security is already "Enabled" (Drive is locked)
190
+ if "enabled" in out and "not enabled" not in out:
191
+ # If it's already locked, we can't wipe without the existing password.
192
+ result.issues.append("Security is ENABLED (Drive is password locked)")
193
+
194
+ # 4. Populate Modes if no fatal issues
195
+ if not result.issues:
196
+ # Enhanced Erase: Usually writes a pattern or destroys encryption keys
197
+ if "enhanced erase" in out:
198
+ result.modes['ATA-Enhanced'] = '--user-master u --security-erase-enhanced NULL'
199
+
200
+ # Normal Erase: Usually writes zeros to the whole platter
201
+ result.modes['ATA-Normal'] = '--user-master u --security-erase NULL'
202
+
203
+ except Exception as e:
204
+ result.issues.append(f"ATA Probe Error: {str(e)}")
205
+ return result
206
+
207
+
208
+ # ============================================================================
209
+ # Part 3: Drive Eraser with Monitoring
210
+ # ============================================================================
211
+
212
+ class DriveEraser:
213
+ """Execute and monitor hardware secure erase"""
214
+
215
+ def __init__(self, progress_callback: Optional[Callable] = None):
216
+ self.status = EraseStatus.NOT_STARTED
217
+ self.start_time = None
218
+ self.progress_callback = progress_callback
219
+ self.monitor_thread = None
220
+ self.current_process = None
221
+
222
+ def run_firmware_wipe(self):
223
+ """The thread target for firmware wipes"""
224
+ self.start_mono = time.monotonic()
225
+
226
+ # 1. Start the process (non-blocking)
227
+ # self.opts.hw_cmd might be: "nvme sanitize --action=0x02"
228
+ full_cmd = f"{self.opts.tool} {self.opts.hw_cmd} {self.device_path}"
229
+ self.process = subprocess.Popen(full_cmd.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
230
+
231
+ # 2. Polling Loop
232
+ while not self._abort_requested:
233
+ if self.process.poll() is not None:
234
+ break
235
+
236
+ # Optional: For NVMe, you can poll 'nvme sanitize-log' here
237
+ # to get actual 0-100% progress and update self.total_written
238
+
239
+ time.sleep(1)
240
+
241
+ # 3. Finalize
242
+ self.finish_mono = time.monotonic()
243
+ if self.process.returncode == 0:
244
+ self.total_written = self.total_size # Mark as done for the UI
245
+
246
+ def abort(self):
247
+ self._abort_requested = True
248
+ if self.process and self.process.poll() is None:
249
+ self.process.terminate() # Try nice first
250
+ time.sleep(0.5)
251
+ self.process.kill() # Then hammer it
252
+
253
+ """
254
+ # Inside get_summary_dict...
255
+ is_hw = getattr(self.opts, 'is_hardware', False)
256
+
257
+ if is_hw:
258
+ # Rate and written bytes don't follow standard rules
259
+ wipe_step["rate"] = "Hardware"
260
+ wipe_step["status"] = "Sanitizing..." if not self.done else "Complete"
261
+
262
+ """
263
+
264
+ def start_nvme_erase(self, device: str) -> bool:
265
+ """Start NVMe secure erase (non-blocking)"""
266
+ try:
267
+ self.current_process = subprocess.Popen(
268
+ ['nvme', 'format', device, '--ses=1'],
269
+ stdout=subprocess.PIPE,
270
+ stderr=subprocess.PIPE,
271
+ text=True
272
+ )
273
+
274
+ self.status = EraseStatus.STARTING
275
+ self.start_time = time.time()
276
+ self._start_monitoring(device, 'nvme')
277
+ return True
278
+
279
+ except Exception as e:
280
+ print(f"Failed to start NVMe erase: {e}")
281
+ self.status = EraseStatus.FAILED
282
+ return False
283
+
284
+ def start_ata_erase(self, device: str, enhanced: bool = True) -> bool:
285
+ """Start ATA secure erase (non-blocking)"""
286
+ try:
287
+ # Build command
288
+ cmd = ['hdparm', '--user-master', 'u']
289
+ if enhanced:
290
+ cmd.extend(['--security-erase-enhanced', 'NULL'])
291
+ else:
292
+ cmd.extend(['--security-erase', 'NULL'])
293
+ cmd.append(device)
294
+
295
+ self.current_process = subprocess.Popen(
296
+ cmd,
297
+ stdout=subprocess.PIPE,
298
+ stderr=subprocess.PIPE,
299
+ text=True
300
+ )
301
+
302
+ self.status = EraseStatus.STARTING
303
+ self.start_time = time.time()
304
+ self._start_monitoring(device, 'ata')
305
+ return True
306
+
307
+ except Exception as e:
308
+ print(f"Failed to start ATA erase: {e}")
309
+ self.status = EraseStatus.FAILED
310
+ return False
311
+
312
+ def _start_monitoring(self, device: str, drive_type: str):
313
+ """Start background monitoring thread"""
314
+ def monitor():
315
+ time.sleep(3) # Let command start
316
+ self.status = EraseStatus.IN_PROGRESS
317
+
318
+ check_interval = 5
319
+ max_checks = 7200 # 10 hours max
320
+
321
+ for _ in range(max_checks):
322
+ # Check if process completed
323
+ if self.current_process and self.current_process.poll() is not None:
324
+ if self.current_process.returncode == 0:
325
+ self.status = EraseStatus.COMPLETE
326
+ else:
327
+ self.status = EraseStatus.FAILED
328
+ break
329
+
330
+ # Update progress callback
331
+ if self.progress_callback:
332
+ elapsed = time.time() - self.start_time
333
+ progress = self._estimate_progress(elapsed, drive_type)
334
+ self.progress_callback(progress, elapsed, self.status)
335
+
336
+ time.sleep(check_interval)
337
+ else:
338
+ self.status = EraseStatus.FAILED
339
+
340
+ self.monitor_thread = threading.Thread(target=monitor, daemon=True)
341
+ self.monitor_thread.start()
342
+
343
+ def _estimate_progress(self, elapsed_seconds: float, drive_type: str) -> float:
344
+ """Estimate fake progress based on typical times"""
345
+ if drive_type == 'nvme':
346
+ progress = min(1.0, elapsed_seconds / 30)
347
+ elif drive_type == 'ata':
348
+ # Very rough estimate - would need drive size for better guess
349
+ progress = min(1.0, elapsed_seconds / 3600)
350
+ else:
351
+ progress = 0.0
352
+
353
+ return progress * 100
354
+
355
+ def get_status(self) -> Dict:
356
+ """Get current status info"""
357
+ elapsed = time.time() - self.start_time if self.start_time else 0
358
+
359
+ return {
360
+ 'status': self.status.value,
361
+ 'elapsed_seconds': elapsed,
362
+ 'monitor_alive': self.monitor_thread and self.monitor_thread.is_alive(),
363
+ 'process_active': self.current_process and self.current_process.poll() is None
364
+ }
365
+
366
+ def wait_for_completion(self, timeout: Optional[float] = None) -> bool:
367
+ """Wait for erase to complete"""
368
+ if not self.current_process:
369
+ return False
370
+
371
+ try:
372
+ return_code = self.current_process.wait(timeout=timeout)
373
+ return return_code == 0
374
+ except subprocess.TimeoutExpired:
375
+ return False
376
+
377
+ # ============================================================================
378
+ # Part 4: Main Wipe Controller (Integration Point)
379
+ # ============================================================================
380
+
381
+ class HardwareWipeController:
382
+ """
383
+ Main controller for hardware wiping.
384
+ This is what you'd integrate into dwipe.
385
+ """
386
+
387
+ def __init__(self, auto_install_tools: bool = False, verbose: bool = False):
388
+ self.tool_mgr = ToolManager(auto_install=auto_install_tools, verbose=verbose)
389
+ self.pre_checker = DrivePreChecker(timeout=15)
390
+ self.eraser = None
391
+ self.verbose = verbose
392
+
393
+ def _log(self, message: str):
394
+ if self.verbose:
395
+ print(f"[HardwareWipe] {message}")
396
+
397
+ def prepare(self) -> bool:
398
+ """Ensure required tools are available"""
399
+ if not self.tool_mgr.ensure_tool('hdparm', critical=True):
400
+ return False
401
+ if not self.tool_mgr.ensure_tool('nvme', critical=True):
402
+ return False
403
+ return True
404
+
405
+ def pre_check(self, device: str) -> PreCheckResult:
406
+ """Perform comprehensive pre-check"""
407
+ self._log(f"Pre-checking {device}...")
408
+ result = self.pre_checker.can_use_hardware_erase(device)
409
+
410
+ if self.verbose:
411
+ print(f"Pre-check for {device}:")
412
+ print(f" Compatible: {result.compatible}")
413
+ print(f" Tool: {result.tool}")
414
+ if result.issues:
415
+ print(f" Issues: {', '.join(result.issues)}")
416
+ if result.recommendation:
417
+ print(f" Recommendation: {result.recommendation}")
418
+
419
+ return result
420
+
421
+ def wipe(self, device: str, fallback_callback: Optional[Callable] = None) -> bool:
422
+ """
423
+ Execute hardware wipe with automatic fallback.
424
+
425
+ Args:
426
+ device: Device path (/dev/sda, /dev/nvme0n1, etc.)
427
+ fallback_callback: Function to call if hardware wipe fails
428
+ Should accept device path and return bool
429
+
430
+ Returns:
431
+ True if wipe succeeded (hardware or software), False otherwise
432
+ """
433
+ if not self.prepare():
434
+ print("Required tools not available")
435
+ return False
436
+
437
+ # Step 1: Pre-check
438
+ pre_check = self.pre_check(device)
439
+
440
+ if not pre_check.compatible:
441
+ print(f"Hardware erase not compatible for {device}:")
442
+ for issue in pre_check.issues:
443
+ print(f" - {issue}")
444
+
445
+ if fallback_callback:
446
+ self._log("Falling back to software wipe...")
447
+ return fallback_callback(device)
448
+ return False
449
+
450
+ # Step 2: Show user what to expect
451
+ tool_name = pre_check.tool
452
+ print(f"Using {tool_name} for hardware secure erase...")
453
+ print("Note: Drive erases in firmware - tool will exit immediately.")
454
+
455
+ if tool_name == 'nvme':
456
+ print("Expected time: 2-10 seconds")
457
+ elif tool_name == 'hdparm' and pre_check.enhanced_supported:
458
+ print("Expected time: 10-60 seconds (enhanced erase)")
459
+ elif tool_name == 'hdparm':
460
+ print("Expected time: 1-3 hours per TB (normal erase)")
461
+
462
+ # Step 3: Start erase
463
+ self.eraser = DriveEraser(progress_callback=self._progress_update)
464
+
465
+ try:
466
+ if tool_name == 'nvme':
467
+ success = self.eraser.start_nvme_erase(device)
468
+ else: # hdparm
469
+ enhanced = pre_check.enhanced_supported
470
+ success = self.eraser.start_ata_erase(device, enhanced)
471
+
472
+ if not success:
473
+ raise RuntimeError("Failed to start erase")
474
+
475
+ # Step 4: Monitor with timeout
476
+ timeout = self._get_timeout(tool_name, device)
477
+ print(f"Waiting up to {timeout//60} minutes for completion...")
478
+
479
+ # Simple spinner while waiting
480
+ spinner = ['|', '/', '-', '\\']
481
+ i = 0
482
+
483
+ while True:
484
+ status = self.eraser.get_status()
485
+
486
+ if status['status'] == EraseStatus.COMPLETE.value:
487
+ print(f"\nHardware secure erase completed successfully!")
488
+ return True
489
+
490
+ elif status['status'] == EraseStatus.FAILED.value:
491
+ print(f"\nHardware secure erase failed")
492
+ break
493
+
494
+ # Show spinner and elapsed time
495
+ elapsed = status['elapsed_seconds']
496
+ print(f"\r{spinner[i % 4]} Erasing... {int(elapsed)}s elapsed", end='')
497
+ i += 1
498
+
499
+ # Check timeout
500
+ if elapsed > timeout:
501
+ print(f"\nTimeout after {timeout} seconds")
502
+ break
503
+
504
+ time.sleep(0.5)
505
+
506
+ # If we get here, hardware failed
507
+ if fallback_callback:
508
+ print("Falling back to software wipe...")
509
+ return fallback_callback(device)
510
+
511
+ return False
512
+
513
+ except Exception as e:
514
+ print(f"Error during hardware erase: {e}")
515
+ if fallback_callback:
516
+ return fallback_callback(device)
517
+ return False
518
+
519
+ def _progress_update(self, progress: float, elapsed: float, status: EraseStatus):
520
+ """Callback for progress updates"""
521
+ if self.verbose:
522
+ print(f"[Progress] {progress:.1f}% - {elapsed:.0f}s - {status.value}")
523
+
524
+ def _get_timeout(self, tool: str, device: str) -> int:
525
+ """Get appropriate timeout based on drive type"""
526
+ if tool == 'nvme':
527
+ return 30 # 30 seconds for NVMe
528
+ elif tool == 'hdparm':
529
+ # Try to get drive size for better timeout
530
+ try:
531
+ size_gb = self._get_drive_size_gb(device)
532
+ # 2 hours per TB, minimum 30 minutes
533
+ hours = max(0.5, (size_gb / 1024) * 2)
534
+ return int(hours * 3600)
535
+ except:
536
+ return 7200 # 2 hours default
537
+ return 3600 # 1 hour default
538
+
539
+ def _get_drive_size_gb(self, device: str) -> float:
540
+ """Get drive size in GB"""
541
+ try:
542
+ # Use blockdev to get size
543
+ result = subprocess.run(
544
+ ['blockdev', '--getsize64', device],
545
+ capture_output=True,
546
+ text=True,
547
+ timeout=5
548
+ )
549
+ if result.returncode == 0:
550
+ size_bytes = int(result.stdout.strip())
551
+ return size_bytes / (1024**3) # Convert to GB
552
+ except:
553
+ pass
554
+ return 500 # Default guess
555
+
556
+ def apply_marker(self):
557
+ # 1. Force the OS to realize the partitions are gone
558
+ subprocess.run(['blockdev', '--rereadpt', self.device_path])
559
+ time.sleep(1) # Give the kernel a breath
560
+
561
+ try:
562
+ with open(self.device_path, 'wb') as f:
563
+ # Clear first 16K
564
+ f.write(b'\x00' * 16384)
565
+ # Seek to 15K
566
+ f.seek(15360)
567
+ f.write(self.generate_json_marker())
568
+ f.flush()
569
+ os.fsync(f.fileno())
570
+ except OSError as e:
571
+ # If this happens, the drive is likely still 'settling' its FTL
572
+ return "RETRY_NEEDED"
573
+
574
+ # ============================================================================
575
+ # Part 5: Example Usage & Integration Helper
576
+ # ============================================================================
577
+
578
+ def example_software_wipe(device: str) -> bool:
579
+ """Example fallback function for software wipe"""
580
+ print(f"[Software] Would wipe {device} with dd/scrub/etc.")
581
+ # Implement your existing software wipe here
582
+ return True
583
+
584
+ def main():
585
+ """Example standalone usage"""
586
+ import argparse
587
+
588
+ parser = argparse.ArgumentParser(description='Hardware Secure Erase Test')
589
+ parser.add_argument('device', help='Device to wipe (e.g., /dev/sda)')
590
+ parser.add_argument('--auto-install', action='store_true',
591
+ help='Automatically install missing tools')
592
+ parser.add_argument('--verbose', '-v', action='store_true',
593
+ help='Verbose output')
594
+ parser.add_argument('--no-fallback', action='store_true',
595
+ help='Don\'t fall back to software wipe')
596
+ args = parser.parse_args()
597
+
598
+ # Create controller
599
+ controller = HardwareWipeController(
600
+ auto_install_tools=args.auto_install,
601
+ verbose=args.verbose
602
+ )
603
+
604
+ # Define fallback
605
+ fallback = None if args.no_fallback else example_software_wipe
606
+
607
+ # Execute wipe
608
+ success = controller.wipe(args.device, fallback_callback=fallback)
609
+
610
+ if success:
611
+ print(f"\n✓ Wipe completed successfully")
612
+ return 0
613
+ else:
614
+ print(f"\n✗ Wipe failed")
615
+ return 1
616
+
617
+ if __name__ == '__main__':
618
+ sys.exit(main())