dwipe 2.0.2__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/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
- try:
93
- # Force OS to re-read partition table (now empty)
94
- subprocess.run(['blockdev', '--rereadpt', self.device_path],
95
- capture_output=True, timeout=5, check=False)
96
- time.sleep(1) # Let kernel settle
97
-
98
- # Prepare marker data
99
- data = {
100
- "unixtime": int(time.time()),
101
- "scrubbed_bytes": self.total_size,
102
- "size_bytes": self.total_size,
103
- "passes": 1,
104
- "mode": self.wipe_name, # e.g., 'Sanitize-Crypto'
105
- "firmware_wipe": True
106
- }
107
- json_data = json.dumps(data).encode('utf-8')
108
-
109
- # Build marker buffer (16KB)
110
- buffer = bytearray(WipeTask.MARKER_SIZE)
111
- buffer[:WipeTask.STATE_OFFSET] = b'\x00' * WipeTask.STATE_OFFSET
112
- buffer[WipeTask.STATE_OFFSET:WipeTask.STATE_OFFSET + len(json_data)] = json_data
113
- remaining = WipeTask.MARKER_SIZE - (WipeTask.STATE_OFFSET + len(json_data))
114
- buffer[WipeTask.STATE_OFFSET + len(json_data):] = b'\x00' * remaining
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
- # Write marker to beginning of device
117
- with open(self.device_path, 'wb') as f:
118
- f.write(buffer)
119
- f.flush()
120
- os.fsync(f.fileno())
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
- except Exception as e:
123
- # Don't fail the whole job if marker write fails
124
- self.exception = f"Marker write warning: {e}"
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
- # Start subprocess (non-blocking)
133
- self.process = subprocess.Popen(
134
- cmd,
135
- stdout=subprocess.PIPE,
136
- stderr=subprocess.PIPE,
137
- text=True
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
- elif completion_status is False:
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
- mono = time.monotonic()
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(int(elapsed)),
224
- "rate": "Firmware",
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 nvme-cli
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
- - 'sanitize --action=0x04' (Crypto Erase)
241
- - 'format --ses=2' (Format with Crypto Erase)
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 'sanitize' in self.command_args:
253
- if 'crypto' in self.command_args or '0x04' in self.command_args:
254
- return 10 # Crypto erase is very fast
255
- elif 'block' in self.command_args or '0x02' in self.command_args:
256
- return 30
257
- else: # Overwrite
258
- return 120
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 nvme command
324
+ """Build NVMe wipe command using NvmeTool
264
325
 
265
326
  Returns:
266
- list: ['nvme', 'sanitize', '--action=0x04', '/dev/nvme0n1']
327
+ None (NvmeTool handles command internally)
267
328
  """
268
- # Parse command_args: 'sanitize --action=0x04'
269
- parts = self.command_args.split()
270
- cmd = ['nvme'] + parts + [self.device_path]
271
- return cmd
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
- def _check_completion(self):
274
- """Check NVMe wipe completion
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
- Can optionally poll 'nvme sanitize-log' for actual progress.
277
- For now, just check if process exited.
278
- """
279
- if self.process and self.process.poll() is not None:
280
- return self.process.returncode == 0
281
- return None
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
- # TODO: Implement real-time progress polling via 'nvme sanitize-log'
284
- # def _get_sanitize_progress(self):
285
- # """Query actual sanitize progress from device"""
286
- # try:
287
- # result = subprocess.run(
288
- # ['nvme', 'sanitize-log', self.device_path, '-o', 'json'],
289
- # capture_output=True, text=True, timeout=5
290
- # )
291
- # if result.returncode == 0:
292
- # data = json.loads(result.stdout)
293
- # # Parse progress from data
294
- # return progress_pct
295
- # except:
296
- # pass
297
- # return None
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 wipe duration
316
-
317
- Enhanced erase: 2-10 minutes (varies by vendor)
318
- Normal erase: ~1 hour per TB
319
- """
320
- if 'enhanced' in self.command_args:
321
- return 600 # 10 minutes for enhanced
322
- else:
323
- # Estimate based on size: 1 hour per TB
324
- size_tb = self.total_size / (1024**4)
325
- hours = max(0.5, size_tb)
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 security erase, we need to:
332
- 1. Set password: hdparm --user-master u --security-set-pass NULL /dev/sda
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
- We'll just build the erase command - password setting happens in run_task
434
+ Returns:
435
+ list: ['hdparm', '--user-master', 'u', '--security-erase', 'NULL', '/dev/sda']
336
436
  """
337
- # Parse: '--user-master u --security-erase-enhanced NULL'
338
- parts = self.command_args.split()
339
- cmd = ['hdparm'] + parts + [self.device_path]
340
- return cmd
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 _set_ata_password(self):
343
- """Set temporary ATA password before erase
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
- Returns:
346
- bool: True if successful
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
- cmd = ['hdparm', '--user-master', 'u', '--security-set-pass', 'NULL', self.device_path]
350
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
351
- return result.returncode == 0
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"Failed to set ATA password: {e}"
354
- return False
708
+ self.exception = f"Zero wipe partition area failed: {str(e)}"
709
+ self.do_abort = True
355
710
 
356
- def run_task(self):
357
- """Execute SATA firmware wipe (overrides base to add password step)"""
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
- # Step 1: Set temporary password
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
- # Step 2: Execute erase command (base class handles this)
366
- super().run_task()
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
- self.exception = traceback.format_exc()
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