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.
@@ -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