dwipe 2.0.1__py3-none-any.whl → 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dwipe/DeviceChangeMonitor.py +244 -0
- dwipe/DeviceInfo.py +703 -177
- dwipe/DeviceWorker.py +566 -0
- dwipe/DiskWipe.py +953 -214
- dwipe/DrivePreChecker.py +203 -0
- dwipe/FirmwareWipeTask.py +865 -0
- dwipe/NvmeTool.py +225 -0
- dwipe/PersistentState.py +45 -16
- dwipe/Prereqs.py +84 -0
- dwipe/SataTool.py +499 -0
- dwipe/StructuredLogger.py +644 -0
- dwipe/Tunables.py +62 -0
- dwipe/Utils.py +298 -3
- dwipe/VerifyTask.py +412 -0
- dwipe/WipeJob.py +631 -171
- dwipe/WipeTask.py +150 -0
- dwipe/WriteTask.py +402 -0
- dwipe/main.py +34 -9
- dwipe-3.0.0.dist-info/METADATA +566 -0
- dwipe-3.0.0.dist-info/RECORD +24 -0
- dwipe/ToolManager.py +0 -637
- dwipe/WipeJobFuture.py +0 -245
- dwipe-2.0.1.dist-info/METADATA +0 -410
- dwipe-2.0.1.dist-info/RECORD +0 -14
- {dwipe-2.0.1.dist-info → dwipe-3.0.0.dist-info}/WHEEL +0 -0
- {dwipe-2.0.1.dist-info → dwipe-3.0.0.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.1.dist-info → dwipe-3.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FirmwareWipeTask - Firmware-based secure erase operations
|
|
3
|
+
|
|
4
|
+
Includes:
|
|
5
|
+
- FirmwareWipeTask: Abstract base class for firmware wipes
|
|
6
|
+
- NvmeWipeTask: NVMe secure erase using nvme-cli
|
|
7
|
+
- SataWipeTask: SATA/ATA secure erase using hdparm
|
|
8
|
+
"""
|
|
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
|
|
13
|
+
import os
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
import random
|
|
17
|
+
import subprocess
|
|
18
|
+
import traceback
|
|
19
|
+
|
|
20
|
+
from .WipeTask import WipeTask
|
|
21
|
+
from .Utils import Utils
|
|
22
|
+
from .SataTool import SataTool
|
|
23
|
+
from .NvmeTool import NvmeTool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FirmwareWipeTask(WipeTask):
|
|
27
|
+
"""Abstract base class for firmware-based wipe operations
|
|
28
|
+
|
|
29
|
+
Firmware wipes execute in the drive's controller, not via CPU writes.
|
|
30
|
+
The host just sends a command and monitors for completion.
|
|
31
|
+
|
|
32
|
+
Progress reporting is estimated since most firmware doesn't report
|
|
33
|
+
real-time progress. NVMe sanitize can optionally poll for actual progress.
|
|
34
|
+
|
|
35
|
+
Subclasses must implement:
|
|
36
|
+
- _build_command(): Return list of command args
|
|
37
|
+
- _check_completion(): Check if wipe is complete
|
|
38
|
+
- _estimate_duration(): Estimate total time in seconds
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, device_path, total_size, opts, command_args, wipe_name):
|
|
42
|
+
"""Initialize firmware wipe task
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
device_path: Path to device (e.g., '/dev/sda', '/dev/nvme0n1')
|
|
46
|
+
total_size: Total size in bytes
|
|
47
|
+
opts: Options namespace
|
|
48
|
+
command_args: Command args from hw_caps (e.g., 'sanitize --action=0x04')
|
|
49
|
+
wipe_name: Human-readable name (e.g., 'Sanitize-Crypto')
|
|
50
|
+
"""
|
|
51
|
+
super().__init__(device_path, total_size, opts)
|
|
52
|
+
|
|
53
|
+
self.command_args = command_args
|
|
54
|
+
self.wipe_name = wipe_name
|
|
55
|
+
self.process = None
|
|
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
|
|
61
|
+
|
|
62
|
+
# Estimated duration for progress reporting
|
|
63
|
+
self.estimated_duration = self._estimate_duration()
|
|
64
|
+
|
|
65
|
+
def _estimate_duration(self):
|
|
66
|
+
"""Estimate total duration in seconds (override in subclasses)
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
int: Estimated seconds for completion
|
|
70
|
+
"""
|
|
71
|
+
return 60 # Default 1 minute
|
|
72
|
+
|
|
73
|
+
def get_display_name(self):
|
|
74
|
+
"""Get display name for firmware wipe"""
|
|
75
|
+
return self.wipe_name
|
|
76
|
+
|
|
77
|
+
def _build_command(self):
|
|
78
|
+
"""Build command list for subprocess (must be implemented by subclasses)
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
list: Command args like ['nvme', 'sanitize', ...]
|
|
82
|
+
"""
|
|
83
|
+
raise NotImplementedError("Subclasses must implement _build_command()")
|
|
84
|
+
|
|
85
|
+
def _check_completion(self):
|
|
86
|
+
"""Check if wipe completed successfully (can be overridden)
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
bool or None: True if done, False if failed, None if still running
|
|
90
|
+
"""
|
|
91
|
+
if self.process and self.process.poll() is not None:
|
|
92
|
+
return self.process.returncode == 0
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def _write_marker(self):
|
|
96
|
+
"""Write completion marker after firmware wipe
|
|
97
|
+
|
|
98
|
+
Firmware wipes erase the entire disk including any existing markers.
|
|
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.
|
|
101
|
+
"""
|
|
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
|
|
137
|
+
|
|
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
|
|
146
|
+
|
|
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
|
|
151
|
+
|
|
152
|
+
def run_task(self):
|
|
153
|
+
"""Execute firmware wipe operation (blocking, runs in thread)"""
|
|
154
|
+
try:
|
|
155
|
+
# Build command
|
|
156
|
+
cmd = self._build_command()
|
|
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
|
+
)
|
|
169
|
+
|
|
170
|
+
# Monitor progress with polling loop
|
|
171
|
+
check_interval = 2 # Check every 2 seconds
|
|
172
|
+
|
|
173
|
+
while not self.do_abort:
|
|
174
|
+
# Check if process completed
|
|
175
|
+
completion_status = self._check_completion()
|
|
176
|
+
|
|
177
|
+
if completion_status is True:
|
|
178
|
+
# Success!
|
|
179
|
+
self.total_written = self.total_size
|
|
180
|
+
self.finish_mono = time.monotonic()
|
|
181
|
+
|
|
182
|
+
# Capture return code
|
|
183
|
+
if self.process:
|
|
184
|
+
self.return_code = self.process.returncode
|
|
185
|
+
|
|
186
|
+
# Write marker after successful firmware wipe
|
|
187
|
+
self._write_marker()
|
|
188
|
+
break
|
|
189
|
+
|
|
190
|
+
if completion_status is False:
|
|
191
|
+
# Failed
|
|
192
|
+
stderr = self.process.stderr.read() if self.process.stderr else ""
|
|
193
|
+
self.stderr_output = stderr # Store for logging
|
|
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
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
# Still running - update estimated progress
|
|
202
|
+
elapsed = time.monotonic() - self.start_mono
|
|
203
|
+
progress_pct = min(1.0, elapsed / self.estimated_duration)
|
|
204
|
+
self.total_written = int(self.total_size * progress_pct)
|
|
205
|
+
|
|
206
|
+
time.sleep(check_interval)
|
|
207
|
+
|
|
208
|
+
# Handle abort
|
|
209
|
+
if self.do_abort and self.process:
|
|
210
|
+
self.process.terminate()
|
|
211
|
+
time.sleep(0.5)
|
|
212
|
+
if self.process.poll() is None:
|
|
213
|
+
self.process.kill()
|
|
214
|
+
|
|
215
|
+
except Exception:
|
|
216
|
+
self.exception = traceback.format_exc()
|
|
217
|
+
finally:
|
|
218
|
+
# Capture elapsed time when task completes
|
|
219
|
+
self.elapsed_secs = int(time.monotonic() - self.start_mono)
|
|
220
|
+
self.done = True
|
|
221
|
+
|
|
222
|
+
def get_status(self):
|
|
223
|
+
"""Get current progress status (thread-safe)
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
tuple: (elapsed_str, pct_str, rate_str, eta_str, more_state)
|
|
227
|
+
"""
|
|
228
|
+
mono = time.monotonic()
|
|
229
|
+
elapsed_time = mono - self.start_mono
|
|
230
|
+
|
|
231
|
+
# Calculate percentage based on estimated progress
|
|
232
|
+
pct = (self.total_written / self.total_size) * 100 if self.total_size > 0 else 0
|
|
233
|
+
pct = min(pct, 100)
|
|
234
|
+
pct_str = f'{int(round(pct))}%'
|
|
235
|
+
|
|
236
|
+
if self.do_abort:
|
|
237
|
+
pct_str = 'STOP'
|
|
238
|
+
|
|
239
|
+
# Show "FW" to indicate firmware operation
|
|
240
|
+
rate_str = 'FW'
|
|
241
|
+
|
|
242
|
+
# Calculate ETA based on estimated duration
|
|
243
|
+
if pct < 100:
|
|
244
|
+
remaining = self.estimated_duration - elapsed_time
|
|
245
|
+
eta_str = Utils.ago_str(max(0, int(remaining)))
|
|
246
|
+
else:
|
|
247
|
+
eta_str = '0'
|
|
248
|
+
|
|
249
|
+
elapsed_str = Utils.ago_str(int(round(elapsed_time)))
|
|
250
|
+
|
|
251
|
+
return elapsed_str, pct_str, rate_str, eta_str, self.more_state
|
|
252
|
+
|
|
253
|
+
def get_summary_dict(self):
|
|
254
|
+
"""Generate summary dictionary for structured logging
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
dict: Summary with step details including command sequence and return codes
|
|
258
|
+
"""
|
|
259
|
+
summary = {
|
|
260
|
+
"step": f"firmware {self.wipe_name} {self.device_path}",
|
|
261
|
+
"elapsed": Utils.ago_str(self.elapsed_secs),
|
|
262
|
+
"rate": self.wipe_name, # Use actual wipe mode (Enhanced, Crypto, etc.)
|
|
263
|
+
"bytes_written": self.total_written,
|
|
264
|
+
"bytes_total": self.total_size,
|
|
265
|
+
"result": "completed" if self.total_written == self.total_size else "partial"
|
|
266
|
+
}
|
|
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
|
+
|
|
284
|
+
|
|
285
|
+
class NvmeWipeTask(FirmwareWipeTask):
|
|
286
|
+
"""NVMe firmware wipe using NvmeTool
|
|
287
|
+
|
|
288
|
+
Supports various sanitize and format operations:
|
|
289
|
+
- Sanitize: Crypto Erase, Block Erase, Overwrite
|
|
290
|
+
- Format: Crypto Erase, User Data Erase
|
|
291
|
+
|
|
292
|
+
Example command_args:
|
|
293
|
+
- 'sanitize_crypto' (Sanitize with Crypto Erase)
|
|
294
|
+
- 'sanitize_block' (Sanitize with Block Erase)
|
|
295
|
+
- 'format_erase' (Format with Crypto Erase)
|
|
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)
|
|
302
|
+
|
|
303
|
+
def _estimate_duration(self):
|
|
304
|
+
"""Estimate NVMe wipe duration from tool capabilities
|
|
305
|
+
|
|
306
|
+
Most NVMe sanitize/format operations complete in seconds.
|
|
307
|
+
Crypto erase: 2-10 seconds
|
|
308
|
+
Block erase: 10-30 seconds
|
|
309
|
+
Overwrite: 30-120 seconds
|
|
310
|
+
"""
|
|
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:
|
|
318
|
+
return 30
|
|
319
|
+
if 'overwrite' in self.wipe_method:
|
|
320
|
+
return 120
|
|
321
|
+
return 30
|
|
322
|
+
|
|
323
|
+
def _build_command(self):
|
|
324
|
+
"""Build NVMe wipe command using NvmeTool
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
None (NvmeTool handles command internally)
|
|
328
|
+
"""
|
|
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}")
|
|
333
|
+
|
|
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'
|
|
338
|
+
|
|
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 []
|
|
345
|
+
|
|
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
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class SataWipeTask(FirmwareWipeTask):
|
|
393
|
+
"""SATA/ATA firmware wipe using hdparm
|
|
394
|
+
|
|
395
|
+
Uses ATA Security Erase command:
|
|
396
|
+
- Normal Erase: Writes zeros to all sectors (slow)
|
|
397
|
+
- Enhanced Erase: Cryptographic erase or vendor-specific (fast)
|
|
398
|
+
|
|
399
|
+
Example command_args:
|
|
400
|
+
- '--user-master u --security-erase NULL'
|
|
401
|
+
- '--user-master u --security-erase-enhanced NULL'
|
|
402
|
+
|
|
403
|
+
Note: Requires setting a temporary password before erase.
|
|
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)
|
|
414
|
+
|
|
415
|
+
def _estimate_duration(self):
|
|
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
|
|
427
|
+
|
|
428
|
+
def _build_command(self):
|
|
429
|
+
"""Build hdparm erase command (sets password first as required for SATA)
|
|
430
|
+
|
|
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
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
list: ['hdparm', '--user-master', 'u', '--security-erase', 'NULL', '/dev/sda']
|
|
436
|
+
"""
|
|
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)}")
|
|
471
|
+
|
|
472
|
+
def get_summary_dict(self):
|
|
473
|
+
"""Generate summary dict with SATA pre-erase command sequence included"""
|
|
474
|
+
summary = super().get_summary_dict()
|
|
475
|
+
|
|
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.
|
|
662
|
+
"""
|
|
663
|
+
try:
|
|
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
|
+
|
|
707
|
+
except Exception as e:
|
|
708
|
+
self.exception = f"Zero wipe partition area failed: {str(e)}"
|
|
709
|
+
self.do_abort = True
|
|
710
|
+
|
|
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
|
+
"""
|
|
750
|
+
try:
|
|
751
|
+
device_name = self.device_path.replace('/dev/', '')
|
|
752
|
+
|
|
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())
|
|
783
|
+
|
|
784
|
+
except Exception:
|
|
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)
|
|
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
|