dwipe 2.0.1__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/DeviceInfo.py +183 -52
- dwipe/DiskWipe.py +495 -180
- dwipe/DrivePreChecker.py +90 -0
- dwipe/FirmwareWipeTask.py +370 -0
- dwipe/LsblkMonitor.py +124 -0
- dwipe/PersistentState.py +26 -8
- dwipe/Prereqs.py +84 -0
- dwipe/StructuredLogger.py +643 -0
- dwipe/ToolManager.py +235 -254
- dwipe/Utils.py +108 -0
- dwipe/VerifyTask.py +410 -0
- dwipe/WipeJob.py +613 -165
- dwipe/WipeTask.py +148 -0
- dwipe/WriteTask.py +402 -0
- dwipe/main.py +14 -9
- {dwipe-2.0.1.dist-info → dwipe-2.0.2.dist-info}/METADATA +69 -33
- dwipe-2.0.2.dist-info/RECORD +21 -0
- dwipe/WipeJobFuture.py +0 -245
- dwipe-2.0.1.dist-info/RECORD +0 -14
- {dwipe-2.0.1.dist-info → dwipe-2.0.2.dist-info}/WHEEL +0 -0
- {dwipe-2.0.1.dist-info → dwipe-2.0.2.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.1.dist-info → dwipe-2.0.2.dist-info}/licenses/LICENSE +0 -0
dwipe/ToolManager.py
CHANGED
|
@@ -18,77 +18,6 @@ from dataclasses import dataclass
|
|
|
18
18
|
# Part 1: Tool Manager (Dependency Management)
|
|
19
19
|
# ============================================================================
|
|
20
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
21
|
# ============================================================================
|
|
93
22
|
# Part 2: Drive Pre-Checks
|
|
94
23
|
# ============================================================================
|
|
@@ -102,7 +31,7 @@ class EraseStatus(Enum):
|
|
|
102
31
|
UNKNOWN = "unknown"
|
|
103
32
|
|
|
104
33
|
@dataclass
|
|
105
|
-
class PreCheckResult:
|
|
34
|
+
class OLD-PreCheckResult:
|
|
106
35
|
compatible: bool = False
|
|
107
36
|
tool: Optional[str] = None
|
|
108
37
|
frozen: bool = False
|
|
@@ -110,21 +39,32 @@ class PreCheckResult:
|
|
|
110
39
|
enhanced_supported: bool = False
|
|
111
40
|
issues: List[str] = None
|
|
112
41
|
recommendation: Optional[str] = None
|
|
113
|
-
|
|
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
|
+
|
|
114
54
|
def __post_init__(self):
|
|
115
55
|
if self.issues is None:
|
|
116
56
|
self.issues = []
|
|
117
57
|
|
|
118
58
|
class DrivePreChecker:
|
|
119
59
|
"""Pre-check drive before attempting secure erase"""
|
|
120
|
-
|
|
60
|
+
|
|
121
61
|
def __init__(self, timeout: int = 10):
|
|
122
62
|
self.timeout = timeout
|
|
123
|
-
|
|
63
|
+
|
|
124
64
|
def is_usb_attached(self, device: str) -> bool:
|
|
125
65
|
"""Check if device is USB-attached"""
|
|
126
66
|
dev_name = os.path.basename(device)
|
|
127
|
-
|
|
67
|
+
|
|
128
68
|
# Check via sysfs
|
|
129
69
|
sys_path = f'/sys/block/{dev_name}'
|
|
130
70
|
if os.path.exists(sys_path):
|
|
@@ -133,7 +73,7 @@ class DrivePreChecker:
|
|
|
133
73
|
real_path = os.path.realpath(sys_path)
|
|
134
74
|
if 'usb' in real_path.lower():
|
|
135
75
|
return True
|
|
136
|
-
|
|
76
|
+
|
|
137
77
|
# Check via udev
|
|
138
78
|
udev_info = subprocess.run(
|
|
139
79
|
['udevadm', 'info', '-q', 'property', '-n', device],
|
|
@@ -145,144 +85,125 @@ class DrivePreChecker:
|
|
|
145
85
|
return True
|
|
146
86
|
except:
|
|
147
87
|
pass
|
|
148
|
-
|
|
88
|
+
|
|
149
89
|
return False
|
|
150
|
-
|
|
90
|
+
|
|
151
91
|
def check_nvme_drive(self, device: str) -> PreCheckResult:
|
|
152
|
-
"""
|
|
153
|
-
result = PreCheckResult(
|
|
154
|
-
|
|
92
|
+
"""Probes NVMe and returns specific command flags for available wipe modes"""
|
|
93
|
+
result = PreCheckResult()
|
|
94
|
+
result.modes = {}
|
|
95
|
+
|
|
155
96
|
try:
|
|
156
|
-
#
|
|
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
|
|
97
|
+
# Get controller capabilities in JSON for easy parsing
|
|
168
98
|
id_ctrl = subprocess.run(
|
|
169
|
-
['nvme', 'id-ctrl', device],
|
|
170
|
-
capture_output=True,
|
|
171
|
-
text=True,
|
|
172
|
-
timeout=self.timeout
|
|
99
|
+
['nvme', 'id-ctrl', device, '-o', 'json'],
|
|
100
|
+
capture_output=True, text=True, timeout=self.timeout
|
|
173
101
|
)
|
|
174
|
-
|
|
102
|
+
|
|
175
103
|
if id_ctrl.returncode != 0:
|
|
176
|
-
result.issues.append(
|
|
104
|
+
result.issues.append("NVMe controller unresponsive")
|
|
177
105
|
return result
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
#
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
+
|
|
199
136
|
except Exception as e:
|
|
200
|
-
result.issues.append(f"
|
|
201
|
-
|
|
137
|
+
result.issues.append(f"Probe Error: {str(e)}")
|
|
138
|
+
|
|
202
139
|
return result
|
|
203
|
-
|
|
140
|
+
|
|
204
141
|
def check_ata_drive(self, device: str) -> PreCheckResult:
|
|
205
|
-
"""
|
|
206
|
-
|
|
207
|
-
|
|
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
|
+
|
|
208
164
|
try:
|
|
209
|
-
|
|
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
|
|
165
|
+
# Get drive info via hdparm
|
|
220
166
|
info = subprocess.run(
|
|
221
167
|
['hdparm', '-I', device],
|
|
222
|
-
capture_output=True,
|
|
223
|
-
text=True,
|
|
224
|
-
timeout=self.timeout
|
|
168
|
+
capture_output=True, text=True, timeout=self.timeout
|
|
225
169
|
)
|
|
226
|
-
|
|
170
|
+
|
|
227
171
|
if info.returncode != 0:
|
|
228
|
-
result.issues.append(
|
|
172
|
+
result.issues.append("Drive not responsive to hdparm")
|
|
229
173
|
return result
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
# Check if
|
|
234
|
-
if
|
|
235
|
-
result.
|
|
236
|
-
result
|
|
237
|
-
|
|
238
|
-
# Check
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
result.issues.append("
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
result.
|
|
258
|
-
|
|
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")
|
|
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
|
+
|
|
267
203
|
except Exception as e:
|
|
268
|
-
result.issues.append(f"
|
|
269
|
-
|
|
204
|
+
result.issues.append(f"ATA Probe Error: {str(e)}")
|
|
270
205
|
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}"])
|
|
206
|
+
|
|
286
207
|
|
|
287
208
|
# ============================================================================
|
|
288
209
|
# Part 3: Drive Eraser with Monitoring
|
|
@@ -290,14 +211,56 @@ class DrivePreChecker:
|
|
|
290
211
|
|
|
291
212
|
class DriveEraser:
|
|
292
213
|
"""Execute and monitor hardware secure erase"""
|
|
293
|
-
|
|
214
|
+
|
|
294
215
|
def __init__(self, progress_callback: Optional[Callable] = None):
|
|
295
216
|
self.status = EraseStatus.NOT_STARTED
|
|
296
217
|
self.start_time = None
|
|
297
218
|
self.progress_callback = progress_callback
|
|
298
219
|
self.monitor_thread = None
|
|
299
220
|
self.current_process = None
|
|
300
|
-
|
|
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
|
+
|
|
301
264
|
def start_nvme_erase(self, device: str) -> bool:
|
|
302
265
|
"""Start NVMe secure erase (non-blocking)"""
|
|
303
266
|
try:
|
|
@@ -307,17 +270,17 @@ class DriveEraser:
|
|
|
307
270
|
stderr=subprocess.PIPE,
|
|
308
271
|
text=True
|
|
309
272
|
)
|
|
310
|
-
|
|
273
|
+
|
|
311
274
|
self.status = EraseStatus.STARTING
|
|
312
275
|
self.start_time = time.time()
|
|
313
276
|
self._start_monitoring(device, 'nvme')
|
|
314
277
|
return True
|
|
315
|
-
|
|
278
|
+
|
|
316
279
|
except Exception as e:
|
|
317
280
|
print(f"Failed to start NVMe erase: {e}")
|
|
318
281
|
self.status = EraseStatus.FAILED
|
|
319
282
|
return False
|
|
320
|
-
|
|
283
|
+
|
|
321
284
|
def start_ata_erase(self, device: str, enhanced: bool = True) -> bool:
|
|
322
285
|
"""Start ATA secure erase (non-blocking)"""
|
|
323
286
|
try:
|
|
@@ -328,33 +291,33 @@ class DriveEraser:
|
|
|
328
291
|
else:
|
|
329
292
|
cmd.extend(['--security-erase', 'NULL'])
|
|
330
293
|
cmd.append(device)
|
|
331
|
-
|
|
294
|
+
|
|
332
295
|
self.current_process = subprocess.Popen(
|
|
333
296
|
cmd,
|
|
334
297
|
stdout=subprocess.PIPE,
|
|
335
298
|
stderr=subprocess.PIPE,
|
|
336
299
|
text=True
|
|
337
300
|
)
|
|
338
|
-
|
|
301
|
+
|
|
339
302
|
self.status = EraseStatus.STARTING
|
|
340
303
|
self.start_time = time.time()
|
|
341
304
|
self._start_monitoring(device, 'ata')
|
|
342
305
|
return True
|
|
343
|
-
|
|
306
|
+
|
|
344
307
|
except Exception as e:
|
|
345
308
|
print(f"Failed to start ATA erase: {e}")
|
|
346
309
|
self.status = EraseStatus.FAILED
|
|
347
310
|
return False
|
|
348
|
-
|
|
311
|
+
|
|
349
312
|
def _start_monitoring(self, device: str, drive_type: str):
|
|
350
313
|
"""Start background monitoring thread"""
|
|
351
314
|
def monitor():
|
|
352
315
|
time.sleep(3) # Let command start
|
|
353
316
|
self.status = EraseStatus.IN_PROGRESS
|
|
354
|
-
|
|
317
|
+
|
|
355
318
|
check_interval = 5
|
|
356
319
|
max_checks = 7200 # 10 hours max
|
|
357
|
-
|
|
320
|
+
|
|
358
321
|
for _ in range(max_checks):
|
|
359
322
|
# Check if process completed
|
|
360
323
|
if self.current_process and self.current_process.poll() is not None:
|
|
@@ -363,20 +326,20 @@ class DriveEraser:
|
|
|
363
326
|
else:
|
|
364
327
|
self.status = EraseStatus.FAILED
|
|
365
328
|
break
|
|
366
|
-
|
|
329
|
+
|
|
367
330
|
# Update progress callback
|
|
368
331
|
if self.progress_callback:
|
|
369
332
|
elapsed = time.time() - self.start_time
|
|
370
333
|
progress = self._estimate_progress(elapsed, drive_type)
|
|
371
334
|
self.progress_callback(progress, elapsed, self.status)
|
|
372
|
-
|
|
335
|
+
|
|
373
336
|
time.sleep(check_interval)
|
|
374
337
|
else:
|
|
375
338
|
self.status = EraseStatus.FAILED
|
|
376
|
-
|
|
339
|
+
|
|
377
340
|
self.monitor_thread = threading.Thread(target=monitor, daemon=True)
|
|
378
341
|
self.monitor_thread.start()
|
|
379
|
-
|
|
342
|
+
|
|
380
343
|
def _estimate_progress(self, elapsed_seconds: float, drive_type: str) -> float:
|
|
381
344
|
"""Estimate fake progress based on typical times"""
|
|
382
345
|
if drive_type == 'nvme':
|
|
@@ -386,25 +349,25 @@ class DriveEraser:
|
|
|
386
349
|
progress = min(1.0, elapsed_seconds / 3600)
|
|
387
350
|
else:
|
|
388
351
|
progress = 0.0
|
|
389
|
-
|
|
352
|
+
|
|
390
353
|
return progress * 100
|
|
391
|
-
|
|
354
|
+
|
|
392
355
|
def get_status(self) -> Dict:
|
|
393
356
|
"""Get current status info"""
|
|
394
357
|
elapsed = time.time() - self.start_time if self.start_time else 0
|
|
395
|
-
|
|
358
|
+
|
|
396
359
|
return {
|
|
397
360
|
'status': self.status.value,
|
|
398
361
|
'elapsed_seconds': elapsed,
|
|
399
362
|
'monitor_alive': self.monitor_thread and self.monitor_thread.is_alive(),
|
|
400
363
|
'process_active': self.current_process and self.current_process.poll() is None
|
|
401
364
|
}
|
|
402
|
-
|
|
365
|
+
|
|
403
366
|
def wait_for_completion(self, timeout: Optional[float] = None) -> bool:
|
|
404
367
|
"""Wait for erase to complete"""
|
|
405
368
|
if not self.current_process:
|
|
406
369
|
return False
|
|
407
|
-
|
|
370
|
+
|
|
408
371
|
try:
|
|
409
372
|
return_code = self.current_process.wait(timeout=timeout)
|
|
410
373
|
return return_code == 0
|
|
@@ -420,17 +383,17 @@ class HardwareWipeController:
|
|
|
420
383
|
Main controller for hardware wiping.
|
|
421
384
|
This is what you'd integrate into dwipe.
|
|
422
385
|
"""
|
|
423
|
-
|
|
386
|
+
|
|
424
387
|
def __init__(self, auto_install_tools: bool = False, verbose: bool = False):
|
|
425
388
|
self.tool_mgr = ToolManager(auto_install=auto_install_tools, verbose=verbose)
|
|
426
389
|
self.pre_checker = DrivePreChecker(timeout=15)
|
|
427
390
|
self.eraser = None
|
|
428
391
|
self.verbose = verbose
|
|
429
|
-
|
|
392
|
+
|
|
430
393
|
def _log(self, message: str):
|
|
431
394
|
if self.verbose:
|
|
432
395
|
print(f"[HardwareWipe] {message}")
|
|
433
|
-
|
|
396
|
+
|
|
434
397
|
def prepare(self) -> bool:
|
|
435
398
|
"""Ensure required tools are available"""
|
|
436
399
|
if not self.tool_mgr.ensure_tool('hdparm', critical=True):
|
|
@@ -438,12 +401,12 @@ class HardwareWipeController:
|
|
|
438
401
|
if not self.tool_mgr.ensure_tool('nvme', critical=True):
|
|
439
402
|
return False
|
|
440
403
|
return True
|
|
441
|
-
|
|
404
|
+
|
|
442
405
|
def pre_check(self, device: str) -> PreCheckResult:
|
|
443
406
|
"""Perform comprehensive pre-check"""
|
|
444
407
|
self._log(f"Pre-checking {device}...")
|
|
445
408
|
result = self.pre_checker.can_use_hardware_erase(device)
|
|
446
|
-
|
|
409
|
+
|
|
447
410
|
if self.verbose:
|
|
448
411
|
print(f"Pre-check for {device}:")
|
|
449
412
|
print(f" Compatible: {result.compatible}")
|
|
@@ -452,112 +415,112 @@ class HardwareWipeController:
|
|
|
452
415
|
print(f" Issues: {', '.join(result.issues)}")
|
|
453
416
|
if result.recommendation:
|
|
454
417
|
print(f" Recommendation: {result.recommendation}")
|
|
455
|
-
|
|
418
|
+
|
|
456
419
|
return result
|
|
457
|
-
|
|
420
|
+
|
|
458
421
|
def wipe(self, device: str, fallback_callback: Optional[Callable] = None) -> bool:
|
|
459
422
|
"""
|
|
460
423
|
Execute hardware wipe with automatic fallback.
|
|
461
|
-
|
|
424
|
+
|
|
462
425
|
Args:
|
|
463
426
|
device: Device path (/dev/sda, /dev/nvme0n1, etc.)
|
|
464
427
|
fallback_callback: Function to call if hardware wipe fails
|
|
465
428
|
Should accept device path and return bool
|
|
466
|
-
|
|
429
|
+
|
|
467
430
|
Returns:
|
|
468
431
|
True if wipe succeeded (hardware or software), False otherwise
|
|
469
432
|
"""
|
|
470
433
|
if not self.prepare():
|
|
471
434
|
print("Required tools not available")
|
|
472
435
|
return False
|
|
473
|
-
|
|
436
|
+
|
|
474
437
|
# Step 1: Pre-check
|
|
475
438
|
pre_check = self.pre_check(device)
|
|
476
|
-
|
|
439
|
+
|
|
477
440
|
if not pre_check.compatible:
|
|
478
441
|
print(f"Hardware erase not compatible for {device}:")
|
|
479
442
|
for issue in pre_check.issues:
|
|
480
443
|
print(f" - {issue}")
|
|
481
|
-
|
|
444
|
+
|
|
482
445
|
if fallback_callback:
|
|
483
446
|
self._log("Falling back to software wipe...")
|
|
484
447
|
return fallback_callback(device)
|
|
485
448
|
return False
|
|
486
|
-
|
|
449
|
+
|
|
487
450
|
# Step 2: Show user what to expect
|
|
488
451
|
tool_name = pre_check.tool
|
|
489
452
|
print(f"Using {tool_name} for hardware secure erase...")
|
|
490
453
|
print("Note: Drive erases in firmware - tool will exit immediately.")
|
|
491
|
-
|
|
454
|
+
|
|
492
455
|
if tool_name == 'nvme':
|
|
493
456
|
print("Expected time: 2-10 seconds")
|
|
494
457
|
elif tool_name == 'hdparm' and pre_check.enhanced_supported:
|
|
495
458
|
print("Expected time: 10-60 seconds (enhanced erase)")
|
|
496
459
|
elif tool_name == 'hdparm':
|
|
497
460
|
print("Expected time: 1-3 hours per TB (normal erase)")
|
|
498
|
-
|
|
461
|
+
|
|
499
462
|
# Step 3: Start erase
|
|
500
463
|
self.eraser = DriveEraser(progress_callback=self._progress_update)
|
|
501
|
-
|
|
464
|
+
|
|
502
465
|
try:
|
|
503
466
|
if tool_name == 'nvme':
|
|
504
467
|
success = self.eraser.start_nvme_erase(device)
|
|
505
468
|
else: # hdparm
|
|
506
469
|
enhanced = pre_check.enhanced_supported
|
|
507
470
|
success = self.eraser.start_ata_erase(device, enhanced)
|
|
508
|
-
|
|
471
|
+
|
|
509
472
|
if not success:
|
|
510
473
|
raise RuntimeError("Failed to start erase")
|
|
511
|
-
|
|
474
|
+
|
|
512
475
|
# Step 4: Monitor with timeout
|
|
513
476
|
timeout = self._get_timeout(tool_name, device)
|
|
514
477
|
print(f"Waiting up to {timeout//60} minutes for completion...")
|
|
515
|
-
|
|
478
|
+
|
|
516
479
|
# Simple spinner while waiting
|
|
517
480
|
spinner = ['|', '/', '-', '\\']
|
|
518
481
|
i = 0
|
|
519
|
-
|
|
482
|
+
|
|
520
483
|
while True:
|
|
521
484
|
status = self.eraser.get_status()
|
|
522
|
-
|
|
485
|
+
|
|
523
486
|
if status['status'] == EraseStatus.COMPLETE.value:
|
|
524
487
|
print(f"\nHardware secure erase completed successfully!")
|
|
525
488
|
return True
|
|
526
|
-
|
|
489
|
+
|
|
527
490
|
elif status['status'] == EraseStatus.FAILED.value:
|
|
528
491
|
print(f"\nHardware secure erase failed")
|
|
529
492
|
break
|
|
530
|
-
|
|
493
|
+
|
|
531
494
|
# Show spinner and elapsed time
|
|
532
495
|
elapsed = status['elapsed_seconds']
|
|
533
496
|
print(f"\r{spinner[i % 4]} Erasing... {int(elapsed)}s elapsed", end='')
|
|
534
497
|
i += 1
|
|
535
|
-
|
|
498
|
+
|
|
536
499
|
# Check timeout
|
|
537
500
|
if elapsed > timeout:
|
|
538
501
|
print(f"\nTimeout after {timeout} seconds")
|
|
539
502
|
break
|
|
540
|
-
|
|
503
|
+
|
|
541
504
|
time.sleep(0.5)
|
|
542
|
-
|
|
505
|
+
|
|
543
506
|
# If we get here, hardware failed
|
|
544
507
|
if fallback_callback:
|
|
545
508
|
print("Falling back to software wipe...")
|
|
546
509
|
return fallback_callback(device)
|
|
547
|
-
|
|
510
|
+
|
|
548
511
|
return False
|
|
549
|
-
|
|
512
|
+
|
|
550
513
|
except Exception as e:
|
|
551
514
|
print(f"Error during hardware erase: {e}")
|
|
552
515
|
if fallback_callback:
|
|
553
516
|
return fallback_callback(device)
|
|
554
517
|
return False
|
|
555
|
-
|
|
518
|
+
|
|
556
519
|
def _progress_update(self, progress: float, elapsed: float, status: EraseStatus):
|
|
557
520
|
"""Callback for progress updates"""
|
|
558
521
|
if self.verbose:
|
|
559
522
|
print(f"[Progress] {progress:.1f}% - {elapsed:.0f}s - {status.value}")
|
|
560
|
-
|
|
523
|
+
|
|
561
524
|
def _get_timeout(self, tool: str, device: str) -> int:
|
|
562
525
|
"""Get appropriate timeout based on drive type"""
|
|
563
526
|
if tool == 'nvme':
|
|
@@ -572,7 +535,7 @@ class HardwareWipeController:
|
|
|
572
535
|
except:
|
|
573
536
|
return 7200 # 2 hours default
|
|
574
537
|
return 3600 # 1 hour default
|
|
575
|
-
|
|
538
|
+
|
|
576
539
|
def _get_drive_size_gb(self, device: str) -> float:
|
|
577
540
|
"""Get drive size in GB"""
|
|
578
541
|
try:
|
|
@@ -590,6 +553,24 @@ class HardwareWipeController:
|
|
|
590
553
|
pass
|
|
591
554
|
return 500 # Default guess
|
|
592
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
|
+
|
|
593
574
|
# ============================================================================
|
|
594
575
|
# Part 5: Example Usage & Integration Helper
|
|
595
576
|
# ============================================================================
|
|
@@ -603,29 +584,29 @@ def example_software_wipe(device: str) -> bool:
|
|
|
603
584
|
def main():
|
|
604
585
|
"""Example standalone usage"""
|
|
605
586
|
import argparse
|
|
606
|
-
|
|
587
|
+
|
|
607
588
|
parser = argparse.ArgumentParser(description='Hardware Secure Erase Test')
|
|
608
589
|
parser.add_argument('device', help='Device to wipe (e.g., /dev/sda)')
|
|
609
|
-
parser.add_argument('--auto-install', action='store_true',
|
|
590
|
+
parser.add_argument('--auto-install', action='store_true',
|
|
610
591
|
help='Automatically install missing tools')
|
|
611
592
|
parser.add_argument('--verbose', '-v', action='store_true',
|
|
612
593
|
help='Verbose output')
|
|
613
594
|
parser.add_argument('--no-fallback', action='store_true',
|
|
614
595
|
help='Don\'t fall back to software wipe')
|
|
615
596
|
args = parser.parse_args()
|
|
616
|
-
|
|
597
|
+
|
|
617
598
|
# Create controller
|
|
618
599
|
controller = HardwareWipeController(
|
|
619
600
|
auto_install_tools=args.auto_install,
|
|
620
601
|
verbose=args.verbose
|
|
621
602
|
)
|
|
622
|
-
|
|
603
|
+
|
|
623
604
|
# Define fallback
|
|
624
605
|
fallback = None if args.no_fallback else example_software_wipe
|
|
625
|
-
|
|
606
|
+
|
|
626
607
|
# Execute wipe
|
|
627
608
|
success = controller.wipe(args.device, fallback_callback=fallback)
|
|
628
|
-
|
|
609
|
+
|
|
629
610
|
if success:
|
|
630
611
|
print(f"\n✓ Wipe completed successfully")
|
|
631
612
|
return 0
|