dwipe 2.0.2__py3-none-any.whl → 3.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/DeviceChangeMonitor.py +244 -0
- dwipe/DeviceInfo.py +592 -193
- dwipe/DeviceWorker.py +572 -0
- dwipe/DiskWipe.py +569 -136
- dwipe/DrivePreChecker.py +161 -48
- dwipe/FirmwareWipeTask.py +627 -132
- dwipe/NvmeTool.py +225 -0
- dwipe/PersistentState.py +20 -9
- dwipe/SataTool.py +499 -0
- dwipe/StructuredLogger.py +4 -3
- dwipe/Tunables.py +62 -0
- dwipe/Utils.py +192 -5
- dwipe/VerifyTask.py +4 -2
- dwipe/WipeJob.py +25 -13
- dwipe/WipeTask.py +4 -2
- dwipe/WriteTask.py +1 -1
- dwipe/main.py +28 -8
- {dwipe-2.0.2.dist-info → dwipe-3.0.1.dist-info}/METADATA +218 -99
- dwipe-3.0.1.dist-info/RECORD +24 -0
- dwipe/LsblkMonitor.py +0 -124
- dwipe/ToolManager.py +0 -618
- dwipe-2.0.2.dist-info/RECORD +0 -21
- {dwipe-2.0.2.dist-info → dwipe-3.0.1.dist-info}/WHEEL +0 -0
- {dwipe-2.0.2.dist-info → dwipe-3.0.1.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.2.dist-info → dwipe-3.0.1.dist-info}/licenses/LICENSE +0 -0
dwipe/FirmwareWipeTask.py
CHANGED
|
@@ -7,15 +7,20 @@ Includes:
|
|
|
7
7
|
- SataWipeTask: SATA/ATA secure erase using hdparm
|
|
8
8
|
"""
|
|
9
9
|
# pylint: disable=broad-exception-raised,broad-exception-caught
|
|
10
|
+
# pylint: disable=invalid-name,too-many-instance-attributes,too-many-arguments
|
|
11
|
+
# pylint: disable=too-many-positional-arguments,consider-using-with
|
|
12
|
+
# pylint: disable=too-many-return-statements
|
|
10
13
|
import os
|
|
11
14
|
import json
|
|
12
15
|
import time
|
|
16
|
+
import random
|
|
13
17
|
import subprocess
|
|
14
18
|
import traceback
|
|
15
|
-
# from types import SimpleNamespace
|
|
16
19
|
|
|
17
20
|
from .WipeTask import WipeTask
|
|
18
21
|
from .Utils import Utils
|
|
22
|
+
from .SataTool import SataTool
|
|
23
|
+
from .NvmeTool import NvmeTool
|
|
19
24
|
|
|
20
25
|
|
|
21
26
|
class FirmwareWipeTask(WipeTask):
|
|
@@ -49,6 +54,10 @@ class FirmwareWipeTask(WipeTask):
|
|
|
49
54
|
self.wipe_name = wipe_name
|
|
50
55
|
self.process = None
|
|
51
56
|
self.finish_mono = None
|
|
57
|
+
self.actual_command = None # Store actual command that was executed
|
|
58
|
+
self.return_code = None # Store process return code
|
|
59
|
+
self.stderr_output = None # Store stderr output on failure
|
|
60
|
+
self.elapsed_secs = 0 # Capture elapsed time when task completes
|
|
52
61
|
|
|
53
62
|
# Estimated duration for progress reporting
|
|
54
63
|
self.estimated_duration = self._estimate_duration()
|
|
@@ -88,54 +97,75 @@ class FirmwareWipeTask(WipeTask):
|
|
|
88
97
|
|
|
89
98
|
Firmware wipes erase the entire disk including any existing markers.
|
|
90
99
|
We need to write a new marker indicating the wipe is complete.
|
|
100
|
+
Retries on I/O errors as drives may need time to stabilize after sanitize.
|
|
91
101
|
"""
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
102
|
+
max_retries = 20
|
|
103
|
+
retry_delay = 1.0 # seconds
|
|
104
|
+
|
|
105
|
+
for attempt in range(max_retries):
|
|
106
|
+
try:
|
|
107
|
+
# Force OS to re-read partition table (now empty)
|
|
108
|
+
subprocess.run(['blockdev', '--rereadpt', self.device_path],
|
|
109
|
+
capture_output=True, timeout=5, check=False)
|
|
110
|
+
time.sleep(0.5) # Let kernel settle
|
|
111
|
+
|
|
112
|
+
# Prepare marker data
|
|
113
|
+
data = {
|
|
114
|
+
"unixtime": int(time.time()),
|
|
115
|
+
"scrubbed_bytes": self.total_size,
|
|
116
|
+
"size_bytes": self.total_size,
|
|
117
|
+
"passes": 1,
|
|
118
|
+
"mode": self.wipe_name, # e.g., 'Crypto', 'Enhanced'
|
|
119
|
+
}
|
|
120
|
+
json_data = json.dumps(data).encode('utf-8')
|
|
121
|
+
|
|
122
|
+
# Build marker buffer (16KB)
|
|
123
|
+
buffer = bytearray(WipeTask.MARKER_SIZE)
|
|
124
|
+
buffer[:WipeTask.STATE_OFFSET] = b'\x00' * WipeTask.STATE_OFFSET
|
|
125
|
+
buffer[WipeTask.STATE_OFFSET:WipeTask.STATE_OFFSET + len(json_data)] = json_data
|
|
126
|
+
remaining = WipeTask.MARKER_SIZE - (WipeTask.STATE_OFFSET + len(json_data))
|
|
127
|
+
buffer[WipeTask.STATE_OFFSET + len(json_data):] = b'\x00' * remaining
|
|
128
|
+
|
|
129
|
+
# Write marker to beginning of device
|
|
130
|
+
with open(self.device_path, 'wb') as f:
|
|
131
|
+
f.write(buffer)
|
|
132
|
+
f.flush()
|
|
133
|
+
os.fsync(f.fileno())
|
|
134
|
+
|
|
135
|
+
# Success
|
|
136
|
+
return
|
|
115
137
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
138
|
+
except OSError as e:
|
|
139
|
+
# I/O errors (errno 5 = EIO) may be transient after sanitize
|
|
140
|
+
if e.errno == 5 and attempt < max_retries - 1:
|
|
141
|
+
time.sleep(retry_delay)
|
|
142
|
+
continue
|
|
143
|
+
# For non-I/O errors or final attempt, give up
|
|
144
|
+
self.exception = f"Marker write warning: {e}"
|
|
145
|
+
return
|
|
121
146
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
147
|
+
except Exception as e:
|
|
148
|
+
# Don't fail the whole job if marker write fails
|
|
149
|
+
self.exception = f"Marker write warning: {e}"
|
|
150
|
+
return
|
|
125
151
|
|
|
126
152
|
def run_task(self):
|
|
127
153
|
"""Execute firmware wipe operation (blocking, runs in thread)"""
|
|
128
154
|
try:
|
|
129
155
|
# Build command
|
|
130
156
|
cmd = self._build_command()
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
157
|
+
# Only set actual_command if cmd is non-empty (subclass may have already set it)
|
|
158
|
+
if cmd:
|
|
159
|
+
self.actual_command = cmd
|
|
160
|
+
|
|
161
|
+
# Start subprocess (non-blocking) - skip if process already started (e.g., NVMe)
|
|
162
|
+
if cmd:
|
|
163
|
+
self.process = subprocess.Popen(
|
|
164
|
+
cmd,
|
|
165
|
+
stdout=subprocess.PIPE,
|
|
166
|
+
stderr=subprocess.PIPE,
|
|
167
|
+
text=True
|
|
168
|
+
)
|
|
139
169
|
|
|
140
170
|
# Monitor progress with polling loop
|
|
141
171
|
check_interval = 2 # Check every 2 seconds
|
|
@@ -149,14 +179,23 @@ class FirmwareWipeTask(WipeTask):
|
|
|
149
179
|
self.total_written = self.total_size
|
|
150
180
|
self.finish_mono = time.monotonic()
|
|
151
181
|
|
|
182
|
+
# Capture return code
|
|
183
|
+
if self.process:
|
|
184
|
+
self.return_code = self.process.returncode
|
|
185
|
+
|
|
152
186
|
# Write marker after successful firmware wipe
|
|
153
187
|
self._write_marker()
|
|
154
188
|
break
|
|
155
189
|
|
|
156
|
-
|
|
190
|
+
if completion_status is False:
|
|
157
191
|
# Failed
|
|
158
192
|
stderr = self.process.stderr.read() if self.process.stderr else ""
|
|
193
|
+
self.stderr_output = stderr # Store for logging
|
|
159
194
|
self.exception = f"Firmware wipe failed: {stderr}"
|
|
195
|
+
|
|
196
|
+
# Capture return code on failure too
|
|
197
|
+
if self.process:
|
|
198
|
+
self.return_code = self.process.returncode
|
|
160
199
|
break
|
|
161
200
|
|
|
162
201
|
# Still running - update estimated progress
|
|
@@ -176,13 +215,15 @@ class FirmwareWipeTask(WipeTask):
|
|
|
176
215
|
except Exception:
|
|
177
216
|
self.exception = traceback.format_exc()
|
|
178
217
|
finally:
|
|
218
|
+
# Capture elapsed time when task completes
|
|
219
|
+
self.elapsed_secs = int(time.monotonic() - self.start_mono)
|
|
179
220
|
self.done = True
|
|
180
221
|
|
|
181
222
|
def get_status(self):
|
|
182
223
|
"""Get current progress status (thread-safe)
|
|
183
224
|
|
|
184
225
|
Returns:
|
|
185
|
-
tuple: (elapsed_str, pct_str, rate_str, eta_str)
|
|
226
|
+
tuple: (elapsed_str, pct_str, rate_str, eta_str, more_state)
|
|
186
227
|
"""
|
|
187
228
|
mono = time.monotonic()
|
|
188
229
|
elapsed_time = mono - self.start_mono
|
|
@@ -207,94 +248,145 @@ class FirmwareWipeTask(WipeTask):
|
|
|
207
248
|
|
|
208
249
|
elapsed_str = Utils.ago_str(int(round(elapsed_time)))
|
|
209
250
|
|
|
210
|
-
return elapsed_str, pct_str, rate_str, eta_str
|
|
251
|
+
return elapsed_str, pct_str, rate_str, eta_str, self.more_state
|
|
211
252
|
|
|
212
253
|
def get_summary_dict(self):
|
|
213
254
|
"""Generate summary dictionary for structured logging
|
|
214
255
|
|
|
215
256
|
Returns:
|
|
216
|
-
dict: Summary with step details
|
|
257
|
+
dict: Summary with step details including command sequence and return codes
|
|
217
258
|
"""
|
|
218
|
-
|
|
219
|
-
elapsed = mono - self.start_mono
|
|
220
|
-
|
|
221
|
-
return {
|
|
259
|
+
summary = {
|
|
222
260
|
"step": f"firmware {self.wipe_name} {self.device_path}",
|
|
223
|
-
"elapsed": Utils.ago_str(
|
|
224
|
-
"rate":
|
|
225
|
-
"command": ' '.join(self._build_command()),
|
|
261
|
+
"elapsed": Utils.ago_str(self.elapsed_secs),
|
|
262
|
+
"rate": self.wipe_name, # Use actual wipe mode (Enhanced, Crypto, etc.)
|
|
226
263
|
"bytes_written": self.total_written,
|
|
227
264
|
"bytes_total": self.total_size,
|
|
228
265
|
"result": "completed" if self.total_written == self.total_size else "partial"
|
|
229
266
|
}
|
|
230
267
|
|
|
268
|
+
# Include actual command (always show for debugging)
|
|
269
|
+
if self.actual_command:
|
|
270
|
+
summary["command"] = ' '.join(self.actual_command) if isinstance(self.actual_command, list) else str(self.actual_command)
|
|
271
|
+
else:
|
|
272
|
+
summary["command"] = "N/A"
|
|
273
|
+
|
|
274
|
+
# Include return code if process completed
|
|
275
|
+
if self.return_code is not None:
|
|
276
|
+
summary["return_code"] = self.return_code
|
|
277
|
+
|
|
278
|
+
# Include stderr output if the command failed
|
|
279
|
+
if self.stderr_output:
|
|
280
|
+
summary["stderr"] = self.stderr_output.strip()
|
|
281
|
+
|
|
282
|
+
return summary
|
|
283
|
+
|
|
231
284
|
|
|
232
285
|
class NvmeWipeTask(FirmwareWipeTask):
|
|
233
|
-
"""NVMe firmware wipe using
|
|
286
|
+
"""NVMe firmware wipe using NvmeTool
|
|
234
287
|
|
|
235
288
|
Supports various sanitize and format operations:
|
|
236
289
|
- Sanitize: Crypto Erase, Block Erase, Overwrite
|
|
237
290
|
- Format: Crypto Erase, User Data Erase
|
|
238
291
|
|
|
239
292
|
Example command_args:
|
|
240
|
-
- '
|
|
241
|
-
- '
|
|
293
|
+
- 'sanitize_crypto' (Sanitize with Crypto Erase)
|
|
294
|
+
- 'sanitize_block' (Sanitize with Block Erase)
|
|
295
|
+
- 'format_erase' (Format with Crypto Erase)
|
|
242
296
|
"""
|
|
297
|
+
def __init__(self, device_path, total_size, opts, command_args, wipe_name):
|
|
298
|
+
self.tool = NvmeTool(device_path)
|
|
299
|
+
self.wipe_method = command_args # e.g., 'sanitize_crypto', 'sanitize_block', 'format_erase'
|
|
300
|
+
self.state_mono = 0
|
|
301
|
+
super().__init__(device_path, total_size, opts, command_args, wipe_name)
|
|
243
302
|
|
|
244
303
|
def _estimate_duration(self):
|
|
245
|
-
"""Estimate NVMe wipe duration
|
|
304
|
+
"""Estimate NVMe wipe duration from tool capabilities
|
|
246
305
|
|
|
247
306
|
Most NVMe sanitize/format operations complete in seconds.
|
|
248
307
|
Crypto erase: 2-10 seconds
|
|
249
308
|
Block erase: 10-30 seconds
|
|
250
309
|
Overwrite: 30-120 seconds
|
|
251
310
|
"""
|
|
252
|
-
if
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
else: # Format
|
|
311
|
+
if self.tool.job.est_secs:
|
|
312
|
+
return self.tool.job.est_secs
|
|
313
|
+
|
|
314
|
+
# Fallback estimates based on method
|
|
315
|
+
if 'crypto' in self.wipe_method:
|
|
316
|
+
return 10
|
|
317
|
+
if 'block' in self.wipe_method:
|
|
260
318
|
return 30
|
|
319
|
+
if 'overwrite' in self.wipe_method:
|
|
320
|
+
return 120
|
|
321
|
+
return 30
|
|
261
322
|
|
|
262
323
|
def _build_command(self):
|
|
263
|
-
"""Build
|
|
324
|
+
"""Build NVMe wipe command using NvmeTool
|
|
264
325
|
|
|
265
326
|
Returns:
|
|
266
|
-
|
|
327
|
+
None (NvmeTool handles command internally)
|
|
267
328
|
"""
|
|
268
|
-
#
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
329
|
+
# Verify capabilities for the specific wipe method
|
|
330
|
+
verdict = self.tool.get_wipe_verdict(method=self.wipe_method)
|
|
331
|
+
if verdict != "OK":
|
|
332
|
+
raise Exception(f"NVMe pre-flight failed: {verdict}")
|
|
272
333
|
|
|
273
|
-
|
|
274
|
-
|
|
334
|
+
# Start wipe using NvmeTool
|
|
335
|
+
self.tool.start_wipe(method=self.wipe_method)
|
|
336
|
+
self.process = self.tool.job.process
|
|
337
|
+
self.more_state = 'nvmeIP'
|
|
275
338
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
return
|
|
339
|
+
# Capture the command for logging in summary
|
|
340
|
+
if self.tool.last_command:
|
|
341
|
+
self.actual_command = self.tool.last_command
|
|
342
|
+
|
|
343
|
+
# Return empty list since process is already started
|
|
344
|
+
return []
|
|
282
345
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
346
|
+
def _check_completion(self):
|
|
347
|
+
"""Check NVMe wipe completion with real-time progress polling"""
|
|
348
|
+
if self.more_state == 'nvmeIP':
|
|
349
|
+
if not self.process:
|
|
350
|
+
self.more_state = 'no-process'
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
# For sanitize operations, poll sanitize-log for actual progress
|
|
354
|
+
if 'sanitize' in self.wipe_method:
|
|
355
|
+
status, percent = self.tool.get_sanitize_status()
|
|
356
|
+
if status is not None:
|
|
357
|
+
# Update progress based on actual sanitize status
|
|
358
|
+
self.total_written = int(self.total_size * (percent / 100.0))
|
|
359
|
+
|
|
360
|
+
# sstat values: 0=Idle (done or not started), 1=In Progress, 2=Success, 3=Failed
|
|
361
|
+
if status == 0 or status == 2:
|
|
362
|
+
# Check if process exited successfully
|
|
363
|
+
if self.process.poll() is not None:
|
|
364
|
+
if self.process.returncode == 0:
|
|
365
|
+
self.more_state = 'Complete'
|
|
366
|
+
return True
|
|
367
|
+
else:
|
|
368
|
+
self.more_state = f'rv={self.process.returncode}'
|
|
369
|
+
return False
|
|
370
|
+
elif status == 3:
|
|
371
|
+
self.more_state = 'sanitize-failed'
|
|
372
|
+
return False
|
|
373
|
+
# status == 1: still in progress
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
# For format operations, just check if process completed
|
|
377
|
+
if self.process.poll() is not None:
|
|
378
|
+
if self.process.returncode == 0:
|
|
379
|
+
self.more_state = 'Complete'
|
|
380
|
+
return True
|
|
381
|
+
else:
|
|
382
|
+
self.more_state = f'rv={self.process.returncode}'
|
|
383
|
+
return False
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
if self.more_state == 'Complete':
|
|
387
|
+
return True
|
|
388
|
+
|
|
389
|
+
return False
|
|
298
390
|
|
|
299
391
|
|
|
300
392
|
class SataWipeTask(FirmwareWipeTask):
|
|
@@ -310,61 +402,464 @@ class SataWipeTask(FirmwareWipeTask):
|
|
|
310
402
|
|
|
311
403
|
Note: Requires setting a temporary password before erase.
|
|
312
404
|
"""
|
|
405
|
+
def __init__(self, device_path, total_size, opts, command_args, wipe_name):
|
|
406
|
+
self.tool = SataTool(device_path)
|
|
407
|
+
self.use_enhanced = 'enhanced' in command_args
|
|
408
|
+
self.sanitize_method = None # e.g., 'sanitize_crypto', 'sanitize_block', 'sanitize_overwrite'
|
|
409
|
+
if command_args.startswith('sanitize_'):
|
|
410
|
+
self.sanitize_method = command_args
|
|
411
|
+
self.state_mono = 0
|
|
412
|
+
self.commands_executed = [] # Store pre-erase commands for logging
|
|
413
|
+
super().__init__(device_path, total_size, opts, command_args, wipe_name)
|
|
313
414
|
|
|
314
415
|
def _estimate_duration(self):
|
|
315
|
-
"""Estimate SATA
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
return int(hours * 3600)
|
|
416
|
+
"""Estimate SATA erase/sanitize duration from drive's reported time"""
|
|
417
|
+
# For sanitize operations, use default estimate
|
|
418
|
+
if self.sanitize_method:
|
|
419
|
+
return 120 # Default 2 minutes for SATA Sanitize
|
|
420
|
+
|
|
421
|
+
# For ATA Security Erase, use drive's reported time
|
|
422
|
+
self.tool.refresh_secures()
|
|
423
|
+
if self.tool.secures and self.tool.secures.erase_est_secs:
|
|
424
|
+
idx = -1 if self.use_enhanced else 0
|
|
425
|
+
return self.tool.secures.erase_est_secs[idx]
|
|
426
|
+
return 4 * 60 * 60 # Default 4 hours for SATA
|
|
327
427
|
|
|
328
428
|
def _build_command(self):
|
|
329
|
-
"""Build hdparm command
|
|
429
|
+
"""Build hdparm erase command (sets password first as required for SATA)
|
|
330
430
|
|
|
331
|
-
For
|
|
332
|
-
|
|
333
|
-
2. Erase: hdparm --user-master u --security-erase NULL /dev/sda
|
|
431
|
+
For ATA Security Erase: Uses SataTool.start_wipe() to execute pre-erase commands
|
|
432
|
+
For SATA Sanitize: Directly executes the sanitize command without password setup
|
|
334
433
|
|
|
335
|
-
|
|
434
|
+
Returns:
|
|
435
|
+
list: ['hdparm', '--user-master', 'u', '--security-erase', 'NULL', '/dev/sda']
|
|
336
436
|
"""
|
|
337
|
-
#
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
437
|
+
# Handle SATA Sanitize methods
|
|
438
|
+
if self.sanitize_method:
|
|
439
|
+
# Sanitize doesn't require password setup
|
|
440
|
+
process = self.tool.start_sanitize_wipe(method=self.sanitize_method)
|
|
441
|
+
self.process = process # Store process reference for monitoring
|
|
442
|
+
self.actual_command = self.tool.last_command
|
|
443
|
+
self.more_state = 'hdparmIP'
|
|
444
|
+
# Return empty list since process is already started
|
|
445
|
+
return []
|
|
446
|
+
|
|
447
|
+
# Handle ATA Security Erase methods
|
|
448
|
+
# Call start_wipe which handles password setting and pre-erase setup
|
|
449
|
+
result = self.tool.start_wipe(use_enhanced=self.use_enhanced, password='NULL', db=False)
|
|
450
|
+
|
|
451
|
+
# Handle error case (returns tuple)
|
|
452
|
+
if isinstance(result, tuple):
|
|
453
|
+
_, message = result
|
|
454
|
+
raise Exception(f"SATA wipe setup failed: {message}")
|
|
455
|
+
|
|
456
|
+
# Handle success case (returns dict with command sequence)
|
|
457
|
+
if isinstance(result, dict):
|
|
458
|
+
# Capture pre-erase commands for logging
|
|
459
|
+
self.commands_executed = result.get('commands_executed', [])
|
|
460
|
+
erase_cmd = result.get('erase_command', [])
|
|
461
|
+
|
|
462
|
+
# Check if we got valid erase command
|
|
463
|
+
if not erase_cmd:
|
|
464
|
+
raise Exception("start_wipe() did not return erase command")
|
|
465
|
+
|
|
466
|
+
self.more_state = 'hdparmIP'
|
|
467
|
+
return erase_cmd
|
|
468
|
+
|
|
469
|
+
# Fallback for unexpected return type
|
|
470
|
+
raise Exception(f"Unexpected return from start_wipe(): {type(result)}")
|
|
341
471
|
|
|
342
|
-
def
|
|
343
|
-
"""
|
|
472
|
+
def get_summary_dict(self):
|
|
473
|
+
"""Generate summary dict with SATA pre-erase command sequence included"""
|
|
474
|
+
summary = super().get_summary_dict()
|
|
344
475
|
|
|
345
|
-
|
|
346
|
-
|
|
476
|
+
# Add pre-erase commands executed (for convincing evidence)
|
|
477
|
+
if self.commands_executed:
|
|
478
|
+
summary["commands_executed"] = self.commands_executed
|
|
479
|
+
|
|
480
|
+
return summary
|
|
481
|
+
|
|
482
|
+
def _check_completion(self):
|
|
483
|
+
"""Check SATA erase completion and verify result"""
|
|
484
|
+
if self.more_state == 'hdparmIP':
|
|
485
|
+
if not self.process:
|
|
486
|
+
self.more_state = 'no-self.process'
|
|
487
|
+
return False
|
|
488
|
+
if self.process.poll() is None:
|
|
489
|
+
return None # still running, same state
|
|
490
|
+
if self.process.returncode != 0:
|
|
491
|
+
self.more_state = f'rv={self.process.returncode}'
|
|
492
|
+
return False
|
|
493
|
+
self.more_state = 'NotReady' # move on
|
|
494
|
+
if self.more_state == 'NotReady':
|
|
495
|
+
rv, why = self.tool.verify_wipe_result()
|
|
496
|
+
if not rv:
|
|
497
|
+
self.more_state = why
|
|
498
|
+
return None
|
|
499
|
+
self.more_state = 'Pause'
|
|
500
|
+
self.state_mono = time.monotonic()
|
|
501
|
+
if self.more_state == 'Pause':
|
|
502
|
+
if time.monotonic() - self.state_mono < 5.0:
|
|
503
|
+
return None # keep waiting
|
|
504
|
+
self.more_state = 'Complete'
|
|
505
|
+
return True
|
|
506
|
+
|
|
507
|
+
return False # unexpected state
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
class StandardPrecheckTask(WipeTask):
|
|
511
|
+
"""Precheck firmware wipe capabilities before proceeding
|
|
512
|
+
|
|
513
|
+
Validates that the selected wipe method is available on the device.
|
|
514
|
+
Applies to both NVMe and SATA firmware wipes.
|
|
515
|
+
"""
|
|
516
|
+
|
|
517
|
+
def __init__(self, device_path, total_size, opts, selected_wipe_type, command_method=None):
|
|
518
|
+
super().__init__(device_path, total_size, opts)
|
|
519
|
+
self.selected_wipe_type = selected_wipe_type
|
|
520
|
+
self.command_method = command_method # The actual method name for get_wipe_verdict()
|
|
521
|
+
self.capabilities_found = {}
|
|
522
|
+
self.method_available = False
|
|
523
|
+
self.elapsed_secs = 0
|
|
524
|
+
|
|
525
|
+
def get_display_name(self):
|
|
526
|
+
"""Get display name for precheck task"""
|
|
527
|
+
return "Precheck"
|
|
528
|
+
|
|
529
|
+
def run_task(self):
|
|
530
|
+
"""Validate that selected wipe method is available on device"""
|
|
531
|
+
try:
|
|
532
|
+
# Determine device type and check capabilities
|
|
533
|
+
if self.device_path.startswith('/dev/nvme'):
|
|
534
|
+
# NVMe device
|
|
535
|
+
tool = NvmeTool(self.device_path)
|
|
536
|
+
# Check if selected method is available (use command_method if provided, else selected_wipe_type)
|
|
537
|
+
method_to_check = self.command_method if self.command_method else self.selected_wipe_type
|
|
538
|
+
verdict = tool.get_wipe_verdict(method=method_to_check)
|
|
539
|
+
if verdict == "OK":
|
|
540
|
+
self.capabilities_found[self.selected_wipe_type] = "available"
|
|
541
|
+
self.method_available = True
|
|
542
|
+
else:
|
|
543
|
+
self.capabilities_found[self.selected_wipe_type] = verdict
|
|
544
|
+
self.method_available = False
|
|
545
|
+
else:
|
|
546
|
+
# SATA device
|
|
547
|
+
tool = SataTool(self.device_path)
|
|
548
|
+
tool.refresh_secures()
|
|
549
|
+
if tool.secures and tool.secures.supported:
|
|
550
|
+
self.capabilities_found[self.selected_wipe_type] = "available"
|
|
551
|
+
self.method_available = True
|
|
552
|
+
else:
|
|
553
|
+
self.capabilities_found[self.selected_wipe_type] = "not_available"
|
|
554
|
+
self.method_available = False
|
|
555
|
+
|
|
556
|
+
if not self.method_available:
|
|
557
|
+
self.exception = f"Selected wipe method '{self.selected_wipe_type}' not available"
|
|
558
|
+
|
|
559
|
+
except Exception:
|
|
560
|
+
if not self.exception:
|
|
561
|
+
self.exception = traceback.format_exc()
|
|
562
|
+
finally:
|
|
563
|
+
# Capture elapsed time when task completes
|
|
564
|
+
self.elapsed_secs = int(time.monotonic() - self.start_mono)
|
|
565
|
+
self.done = True
|
|
566
|
+
|
|
567
|
+
def get_summary_dict(self):
|
|
568
|
+
"""Generate summary dictionary for structured logging"""
|
|
569
|
+
summary = {
|
|
570
|
+
"step": f"precheck firmware capabilities {self.device_path}",
|
|
571
|
+
"elapsed": Utils.ago_str(self.elapsed_secs),
|
|
572
|
+
"rate": "Precheck",
|
|
573
|
+
"capabilities_found": self.capabilities_found,
|
|
574
|
+
"selected_method": self.selected_wipe_type,
|
|
575
|
+
"result": "passed" if self.method_available else "failed"
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
# Include error message if precheck failed
|
|
579
|
+
if self.exception:
|
|
580
|
+
summary["error"] = self.exception
|
|
581
|
+
|
|
582
|
+
return summary
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
class FirmwarePreVerifyTask(WipeTask):
|
|
586
|
+
"""Write test blocks before firmware wipe for later verification
|
|
587
|
+
|
|
588
|
+
First performs a 256KB zero wipe from offset 0 using direct I/O to ensure
|
|
589
|
+
partition tables and firmware headers are cleared before test blocks are written.
|
|
590
|
+
|
|
591
|
+
Then writes 3 x 4KB blocks of pseudo-random data at:
|
|
592
|
+
- Front: 16KB after marker area
|
|
593
|
+
- Middle: total_size // 2
|
|
594
|
+
- End: total_size - 4096
|
|
595
|
+
|
|
596
|
+
Stores blocks in self.original_blocks for post-verify to compare against.
|
|
597
|
+
"""
|
|
598
|
+
|
|
599
|
+
BLOCK_SIZE = 4096
|
|
600
|
+
NUM_BLOCKS = 3
|
|
601
|
+
ZERO_WIPE_SIZE = 256 * 1024 # 256KB to clear partition tables and firmware headers
|
|
602
|
+
|
|
603
|
+
def __init__(self, device_path, total_size, opts):
|
|
604
|
+
super().__init__(device_path, total_size, opts)
|
|
605
|
+
self.blocks_written = 0
|
|
606
|
+
self.original_blocks = [] # Stores 3 x 4KB test blocks
|
|
607
|
+
self.test_locations = [] # Stores 3 offsets
|
|
608
|
+
self.elapsed_secs = 0 # Capture elapsed time when task completes
|
|
609
|
+
self.zero_wipe_bytes = 0 # Track bytes written during zero wipe
|
|
610
|
+
|
|
611
|
+
def get_display_name(self):
|
|
612
|
+
"""Get display name for pre-verify task"""
|
|
613
|
+
return "Pre-verify"
|
|
614
|
+
|
|
615
|
+
def run_task(self):
|
|
616
|
+
"""First zero wipe 256KB from offset 0, then write 3 test blocks"""
|
|
617
|
+
try:
|
|
618
|
+
# Phase 1: Zero wipe 256KB from offset 0 using direct I/O
|
|
619
|
+
# This clears partition tables and firmware headers
|
|
620
|
+
self._zero_wipe_partition_area()
|
|
621
|
+
|
|
622
|
+
# Phase 2: Calculate locations for test blocks (skip marker area at front)
|
|
623
|
+
self.test_locations = [
|
|
624
|
+
WipeTask.MARKER_SIZE + 16384, # Front: 16KB after marker
|
|
625
|
+
self.total_size // 2, # Middle
|
|
626
|
+
self.total_size - self.BLOCK_SIZE # End
|
|
627
|
+
]
|
|
628
|
+
|
|
629
|
+
# Generate pseudo-random test data
|
|
630
|
+
random.seed(int(time.time()))
|
|
631
|
+
for i in range(self.NUM_BLOCKS):
|
|
632
|
+
block = bytes([random.randint(0, 255) for _ in range(self.BLOCK_SIZE)])
|
|
633
|
+
self.original_blocks.append(block)
|
|
634
|
+
|
|
635
|
+
# Phase 3: Write blocks to device
|
|
636
|
+
with open(self.device_path, 'r+b') as device:
|
|
637
|
+
for i, offset in enumerate(self.test_locations):
|
|
638
|
+
if self.do_abort:
|
|
639
|
+
break
|
|
640
|
+
|
|
641
|
+
device.seek(offset)
|
|
642
|
+
device.write(self.original_blocks[i])
|
|
643
|
+
device.flush()
|
|
644
|
+
self.blocks_written += 1
|
|
645
|
+
# Total written includes both zero wipe and test blocks
|
|
646
|
+
self.total_written = self.zero_wipe_bytes + (self.blocks_written * self.BLOCK_SIZE)
|
|
647
|
+
|
|
648
|
+
os.fsync(device.fileno())
|
|
649
|
+
|
|
650
|
+
except Exception:
|
|
651
|
+
self.exception = traceback.format_exc()
|
|
652
|
+
finally:
|
|
653
|
+
# Capture elapsed time when task completes (not when logged later)
|
|
654
|
+
self.elapsed_secs = int(time.monotonic() - self.start_mono)
|
|
655
|
+
self.done = True
|
|
656
|
+
|
|
657
|
+
def _zero_wipe_partition_area(self):
|
|
658
|
+
"""Zero wipe 256KB from offset 0 using direct I/O to clear partition tables
|
|
659
|
+
|
|
660
|
+
Uses the same O_DIRECT mechanism as logical wipes to ensure partition
|
|
661
|
+
tables and firmware headers are cleared before test blocks are written.
|
|
347
662
|
"""
|
|
348
663
|
try:
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
664
|
+
# Open device with O_DIRECT for unbuffered I/O
|
|
665
|
+
fd = os.open(self.device_path, os.O_WRONLY | os.O_DIRECT)
|
|
666
|
+
|
|
667
|
+
try:
|
|
668
|
+
# Seek to start of device
|
|
669
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
670
|
+
|
|
671
|
+
bytes_to_write = self.ZERO_WIPE_SIZE
|
|
672
|
+
bytes_written_total = 0
|
|
673
|
+
|
|
674
|
+
while bytes_written_total < bytes_to_write and not self.do_abort:
|
|
675
|
+
# Calculate chunk size (must be block-aligned for O_DIRECT)
|
|
676
|
+
remaining = bytes_to_write - bytes_written_total
|
|
677
|
+
chunk_size = min(WipeTask.WRITE_SIZE, remaining)
|
|
678
|
+
# Round down to block boundary
|
|
679
|
+
chunk_size = (chunk_size // WipeTask.BLOCK_SIZE) * WipeTask.BLOCK_SIZE
|
|
680
|
+
if chunk_size == 0:
|
|
681
|
+
break
|
|
682
|
+
|
|
683
|
+
# Get zero buffer from WipeTask
|
|
684
|
+
chunk = WipeTask.zero_buffer[:chunk_size]
|
|
685
|
+
|
|
686
|
+
try:
|
|
687
|
+
# Write with O_DIRECT (bypasses page cache)
|
|
688
|
+
bytes_written = os.write(fd, chunk)
|
|
689
|
+
except Exception as e:
|
|
690
|
+
self.exception = f"Zero wipe failed: {str(e)}"
|
|
691
|
+
self.do_abort = True
|
|
692
|
+
bytes_written = 0
|
|
693
|
+
|
|
694
|
+
bytes_written_total += bytes_written
|
|
695
|
+
self.zero_wipe_bytes = bytes_written_total
|
|
696
|
+
self.total_written = bytes_written_total
|
|
697
|
+
|
|
698
|
+
# Check for incomplete writes
|
|
699
|
+
if bytes_written < chunk_size:
|
|
700
|
+
break
|
|
701
|
+
|
|
702
|
+
finally:
|
|
703
|
+
# Close device file descriptor
|
|
704
|
+
if fd is not None:
|
|
705
|
+
os.close(fd)
|
|
706
|
+
|
|
352
707
|
except Exception as e:
|
|
353
|
-
self.exception = f"
|
|
354
|
-
|
|
708
|
+
self.exception = f"Zero wipe partition area failed: {str(e)}"
|
|
709
|
+
self.do_abort = True
|
|
355
710
|
|
|
356
|
-
def
|
|
357
|
-
"""
|
|
711
|
+
def get_summary_dict(self):
|
|
712
|
+
"""Generate summary dictionary for structured logging"""
|
|
713
|
+
return {
|
|
714
|
+
"step": "pre-verify test blocks",
|
|
715
|
+
"elapsed": Utils.ago_str(self.elapsed_secs),
|
|
716
|
+
"rate": "Test",
|
|
717
|
+
"blocks_written": self.blocks_written,
|
|
718
|
+
"result": "completed" if self.blocks_written == self.NUM_BLOCKS else "partial"
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
class FirmwarePostVerifyTask(WipeTask):
|
|
723
|
+
"""Verify firmware wipe effectiveness by checking if test blocks changed
|
|
724
|
+
|
|
725
|
+
Reads the 3 test blocks written by FirmwarePreVerifyTask and verifies
|
|
726
|
+
that at least 95% of bytes have changed (indicating effective wipe).
|
|
727
|
+
|
|
728
|
+
Reads block data from the pre-verify task via self.job.tasks.
|
|
729
|
+
"""
|
|
730
|
+
|
|
731
|
+
THRESHOLD_PCT = 95.0 # Require 95%+ bytes different
|
|
732
|
+
|
|
733
|
+
def __init__(self, device_path, total_size, opts):
|
|
734
|
+
super().__init__(device_path, total_size, opts)
|
|
735
|
+
self.diff_percentages = []
|
|
736
|
+
self.verification_passed = False
|
|
737
|
+
self.elapsed_secs = 0 # Capture elapsed time when task completes
|
|
738
|
+
|
|
739
|
+
def get_display_name(self):
|
|
740
|
+
"""Get display name for post-verify task"""
|
|
741
|
+
return "Post-verify"
|
|
742
|
+
|
|
743
|
+
def _update_marker_verify_status(self, verify_status):
|
|
744
|
+
"""Update the wipe marker with verification status (pass/fail)
|
|
745
|
+
|
|
746
|
+
Reads the existing marker written by FirmwareWipeTask, updates it with
|
|
747
|
+
the verification result, and writes it back. This provides the checkmark
|
|
748
|
+
display in the UI when verification passes.
|
|
749
|
+
"""
|
|
358
750
|
try:
|
|
359
|
-
|
|
360
|
-
if not self._set_ata_password():
|
|
361
|
-
self.exception = "Failed to set ATA security password"
|
|
362
|
-
self.done = True
|
|
363
|
-
return
|
|
751
|
+
device_name = self.device_path.replace('/dev/', '')
|
|
364
752
|
|
|
365
|
-
#
|
|
366
|
-
|
|
753
|
+
# Read existing marker
|
|
754
|
+
from .WipeJob import WipeJob
|
|
755
|
+
marker = WipeJob.read_marker_buffer(device_name)
|
|
756
|
+
if not marker:
|
|
757
|
+
return # No marker to update
|
|
758
|
+
|
|
759
|
+
# Prepare updated marker data
|
|
760
|
+
data = {
|
|
761
|
+
"unixtime": marker.unixtime,
|
|
762
|
+
"scrubbed_bytes": marker.scrubbed_bytes,
|
|
763
|
+
"size_bytes": marker.size_bytes,
|
|
764
|
+
"passes": marker.passes,
|
|
765
|
+
"mode": marker.mode,
|
|
766
|
+
"verify_status": verify_status, # "pass" or "fail"
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
json_data = json.dumps(data).encode('utf-8')
|
|
770
|
+
|
|
771
|
+
# Build marker buffer (16KB)
|
|
772
|
+
buffer = bytearray(WipeTask.MARKER_SIZE)
|
|
773
|
+
buffer[:WipeTask.STATE_OFFSET] = b'\x00' * WipeTask.STATE_OFFSET
|
|
774
|
+
buffer[WipeTask.STATE_OFFSET:WipeTask.STATE_OFFSET + len(json_data)] = json_data
|
|
775
|
+
remaining = WipeTask.MARKER_SIZE - (WipeTask.STATE_OFFSET + len(json_data))
|
|
776
|
+
buffer[WipeTask.STATE_OFFSET + len(json_data):] = b'\x00' * remaining
|
|
777
|
+
|
|
778
|
+
# Write updated marker to beginning of device
|
|
779
|
+
with open(self.device_path, 'wb') as f:
|
|
780
|
+
f.write(buffer)
|
|
781
|
+
f.flush()
|
|
782
|
+
os.fsync(f.fileno())
|
|
367
783
|
|
|
368
784
|
except Exception:
|
|
369
|
-
|
|
785
|
+
# Don't fail the job if marker update fails
|
|
786
|
+
pass
|
|
787
|
+
|
|
788
|
+
def run_task(self):
|
|
789
|
+
"""Read test blocks and verify they differ from originals by 95%+"""
|
|
790
|
+
try:
|
|
791
|
+
# Brief delay to allow NVMe drives to finalize wipe operation
|
|
792
|
+
# Some firmware implementations need a moment before reads are reliable
|
|
793
|
+
time.sleep(0.5)
|
|
794
|
+
|
|
795
|
+
# Find pre-verify task in job's task list
|
|
796
|
+
pre_verify_task = None
|
|
797
|
+
for task in self.job.tasks:
|
|
798
|
+
if isinstance(task, FirmwarePreVerifyTask):
|
|
799
|
+
pre_verify_task = task
|
|
800
|
+
break
|
|
801
|
+
|
|
802
|
+
if not pre_verify_task or not pre_verify_task.original_blocks:
|
|
803
|
+
raise Exception("No pre-verify test blocks found")
|
|
804
|
+
|
|
805
|
+
original_blocks = pre_verify_task.original_blocks
|
|
806
|
+
test_locations = pre_verify_task.test_locations
|
|
807
|
+
block_size = FirmwarePreVerifyTask.BLOCK_SIZE
|
|
808
|
+
|
|
809
|
+
# Read current blocks from device
|
|
810
|
+
with open(self.device_path, 'rb') as device:
|
|
811
|
+
for i, offset in enumerate(test_locations):
|
|
812
|
+
if self.do_abort:
|
|
813
|
+
break
|
|
814
|
+
|
|
815
|
+
device.seek(offset)
|
|
816
|
+
current_block = device.read(block_size)
|
|
817
|
+
|
|
818
|
+
if len(current_block) != block_size:
|
|
819
|
+
raise Exception(f"Failed to read block {i} at offset {offset}")
|
|
820
|
+
|
|
821
|
+
# Calculate bytes that differ
|
|
822
|
+
diff_bytes = sum(1 for j in range(block_size)
|
|
823
|
+
if current_block[j] != original_blocks[i][j])
|
|
824
|
+
|
|
825
|
+
diff_pct = (diff_bytes / block_size) * 100.0
|
|
826
|
+
self.diff_percentages.append(round(diff_pct, 1))
|
|
827
|
+
self.total_written = (i + 1) * block_size
|
|
828
|
+
|
|
829
|
+
# Check if all blocks meet threshold
|
|
830
|
+
self.verification_passed = all(pct >= self.THRESHOLD_PCT
|
|
831
|
+
for pct in self.diff_percentages)
|
|
832
|
+
|
|
833
|
+
if not self.verification_passed:
|
|
834
|
+
min_pct = min(self.diff_percentages)
|
|
835
|
+
self.exception = (f"Firmware wipe verification FAILED: "
|
|
836
|
+
f"Test block changed only {min_pct}% "
|
|
837
|
+
f"(threshold: {self.THRESHOLD_PCT}%)")
|
|
838
|
+
|
|
839
|
+
# Update marker with verification status
|
|
840
|
+
verify_status = "pass" if self.verification_passed else "fail"
|
|
841
|
+
self._update_marker_verify_status(verify_status)
|
|
842
|
+
|
|
843
|
+
except Exception as e:
|
|
844
|
+
if not self.exception:
|
|
845
|
+
self.exception = str(e) if str(e) else traceback.format_exc()
|
|
846
|
+
finally:
|
|
847
|
+
# Capture elapsed time when task completes (not when logged later)
|
|
848
|
+
self.elapsed_secs = int(time.monotonic() - self.start_mono)
|
|
370
849
|
self.done = True
|
|
850
|
+
|
|
851
|
+
def get_summary_dict(self):
|
|
852
|
+
"""Generate summary dictionary for structured logging"""
|
|
853
|
+
summary = {
|
|
854
|
+
"step": "post-verify test blocks",
|
|
855
|
+
"elapsed": Utils.ago_str(self.elapsed_secs),
|
|
856
|
+
"rate": "Test",
|
|
857
|
+
"diff_pct": self.diff_percentages,
|
|
858
|
+
"result": "pass" if self.verification_passed else "fail"
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
# Include error message if verification failed
|
|
862
|
+
if self.exception:
|
|
863
|
+
summary["error"] = self.exception.strip()
|
|
864
|
+
|
|
865
|
+
return summary
|