dwipe 2.0.2__py3-none-any.whl → 3.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dwipe/Utils.py CHANGED
@@ -3,6 +3,7 @@ Utils class - Utility functions for dwipe
3
3
  """
4
4
  import os
5
5
  import sys
6
+ import pwd
6
7
  import datetime
7
8
  from pathlib import Path
8
9
  from .StructuredLogger import StructuredLogger
@@ -81,7 +82,6 @@ class Utils:
81
82
  real_user = os.environ.get('SUDO_USER')
82
83
  if real_user:
83
84
  # Running with sudo - get the real user's home directory
84
- import pwd
85
85
  real_home = pwd.getpwnam(real_user).pw_dir
86
86
  config_dir = Path(real_home) / '.config' / 'dwipe'
87
87
  else:
@@ -93,7 +93,6 @@ class Utils:
93
93
  # Fix ownership if running with sudo
94
94
  if real_user:
95
95
  try:
96
- import pwd
97
96
  pw_record = pwd.getpwnam(real_user)
98
97
  uid, gid = pw_record.pw_uid, pw_record.pw_gid
99
98
  os.chown(config_dir, uid, gid)
@@ -112,7 +111,6 @@ class Utils:
112
111
  real_user = os.environ.get('SUDO_USER')
113
112
  if real_user:
114
113
  try:
115
- import pwd
116
114
  pw_record = pwd.getpwnam(real_user)
117
115
  uid, gid = pw_record.pw_uid, pw_record.pw_gid
118
116
  os.chown(file_path, uid, gid)
@@ -207,8 +205,19 @@ class Utils:
207
205
  result_str = summary['result']
208
206
  size_str = device['size']
209
207
  time_str = summary['total_elapsed']
210
- # Get rate from first step (wipe step)
211
- rate_str = summary['steps'][0]['rate'] if summary['steps'] else 'N/A'
208
+ # Get rate from wipe step (skip precheck/verify steps for firmware wipes)
209
+ rate_str = 'N/A'
210
+ for step in summary['steps']:
211
+ step_name = step.get('step', '').lower()
212
+ # Look for actual wipe steps: firmware wipes start with "firmware",
213
+ # software wipes have "writing" in the step name
214
+ # Skip precheck, pre-verify, post-verify steps
215
+ if step_name.startswith('firmware') or 'writing' in step_name:
216
+ rate_str = step.get('rate', 'N/A')
217
+ break
218
+ # Fallback to first step if no wipe step found
219
+ if rate_str == 'N/A' and summary['steps']:
220
+ rate_str = summary['steps'][0].get('rate', 'N/A')
212
221
 
213
222
  # Build base message
214
223
  operation = plan['operation'].capitalize()
@@ -305,3 +314,181 @@ class Utils:
305
314
  Utils.fix_file_ownership(log_path)
306
315
  except Exception:
307
316
  pass # Don't fail if logging fails
317
+
318
+ @staticmethod
319
+ def parse_nvme_sanitize_capabilities(id_ctrl_data: dict) -> dict:
320
+ """Parse NVMe sanitize capabilities from id-ctrl JSON data.
321
+
322
+ Extracts the sanicap field and interprets the capability bits according
323
+ to the NVMe specification:
324
+ - Bit 0: Crypto Erase
325
+ - Bit 1: Block Erase
326
+ - Bit 2: Overwrite
327
+
328
+ Args:
329
+ id_ctrl_data: Dictionary from nvme id-ctrl command (with -o json)
330
+
331
+ Returns:
332
+ dict: Maps capability names to method names, e.g.,
333
+ {'Crypto': 'sanitize_crypto', 'Block': 'sanitize_block'}
334
+ """
335
+ modes = {}
336
+ sanicap = id_ctrl_data.get('sanicap', 0)
337
+
338
+ if sanicap > 0:
339
+ if sanicap & 0x01:
340
+ modes['Crypto'] = 'sanitize_crypto'
341
+ if sanicap & 0x02:
342
+ modes['Block'] = 'sanitize_block'
343
+ if sanicap & 0x04:
344
+ modes['Ovwr'] = 'sanitize_overwrite'
345
+
346
+ return modes
347
+
348
+ @staticmethod
349
+ def parse_nvme_sanitize_flags(id_ctrl_data: dict) -> tuple:
350
+ """Parse NVMe sanitize capabilities into individual boolean flags.
351
+
352
+ Interprets sanicap bits according to NVMe specification:
353
+ - Bit 0: Crypto Erase
354
+ - Bit 1: Block Erase
355
+ - Bit 2: Overwrite
356
+
357
+ Args:
358
+ id_ctrl_data: Dictionary from nvme id-ctrl command (with -o json)
359
+
360
+ Returns:
361
+ tuple: (has_sanitize, crypto_erase_supported, block_erase_supported,
362
+ overwrite_supported)
363
+ """
364
+ sanicap = id_ctrl_data.get('sanicap', 0)
365
+ has_sanitize = sanicap > 0
366
+ crypto_erase_supported = bool(sanicap & 0x01)
367
+ block_erase_supported = bool(sanicap & 0x02)
368
+ overwrite_supported = bool(sanicap & 0x04)
369
+
370
+ return has_sanitize, crypto_erase_supported, block_erase_supported, overwrite_supported
371
+
372
+
373
+ class ClipboardHelper:
374
+ """Helper for copying text to clipboard across different Linux environments.
375
+
376
+ Supports:
377
+ - OSC 52 escape sequence (works over SSH if terminal supports it)
378
+ - Wayland (wl-copy)
379
+ - X11 (xclip, xsel)
380
+ - Terminal fallback (print to screen when clipboard unavailable)
381
+
382
+ Detection priority:
383
+ 1. If SSH session detected -> use OSC 52 (terminal clipboard escape sequence)
384
+ 2. If WAYLAND_DISPLAY set -> try wl-copy
385
+ 3. If DISPLAY set -> try xclip, then xsel
386
+ 4. Fallback -> terminal mode (manual copy)
387
+ """
388
+ import subprocess
389
+ import shutil
390
+
391
+ # Singleton instance for caching probe results
392
+ _instance = None
393
+ _probed = False
394
+ _clipboard_cmd = None # e.g., ['wl-copy'] or ['xclip', '-selection', 'clipboard']
395
+ _use_osc52 = False # Use OSC 52 terminal escape sequence
396
+ _is_ssh = False
397
+ _method_name = None # Human-readable method name
398
+
399
+ @classmethod
400
+ def _probe(cls):
401
+ """Probe available clipboard methods (called once, results cached)."""
402
+ if cls._probed:
403
+ return
404
+
405
+ cls._probed = True
406
+ import shutil
407
+
408
+ # Check for SSH session - use OSC 52 escape sequence
409
+ cls._is_ssh = bool(os.environ.get('SSH_CLIENT') or os.environ.get('SSH_TTY')
410
+ or os.environ.get('SSH_CONNECTION'))
411
+
412
+ if cls._is_ssh:
413
+ # OSC 52 works through terminal emulators that support it
414
+ # (iTerm2, kitty, alacritty, Windows Terminal, tmux with set-clipboard)
415
+ cls._use_osc52 = True
416
+ cls._method_name = 'OSC 52 (terminal escape sequence)'
417
+ return
418
+
419
+ # Check Wayland first
420
+ if os.environ.get('WAYLAND_DISPLAY'):
421
+ if shutil.which('wl-copy'):
422
+ cls._clipboard_cmd = ['wl-copy']
423
+ cls._method_name = 'wl-copy (Wayland)'
424
+ return
425
+
426
+ # Check X11
427
+ if os.environ.get('DISPLAY'):
428
+ if shutil.which('xclip'):
429
+ cls._clipboard_cmd = ['xclip', '-selection', 'clipboard']
430
+ cls._method_name = 'xclip (X11)'
431
+ return
432
+ if shutil.which('xsel'):
433
+ cls._clipboard_cmd = ['xsel', '--clipboard', '--input']
434
+ cls._method_name = 'xsel (X11)'
435
+ return
436
+
437
+ cls._method_name = 'terminal (no clipboard tool found)'
438
+
439
+ @classmethod
440
+ def get_method_name(cls):
441
+ """Return human-readable description of clipboard method."""
442
+ cls._probe()
443
+ return cls._method_name
444
+
445
+ @classmethod
446
+ def has_clipboard(cls):
447
+ """Return True if a clipboard method is available (tool or OSC 52)."""
448
+ cls._probe()
449
+ return cls._clipboard_cmd is not None or cls._use_osc52
450
+
451
+ @classmethod
452
+ def copy(cls, text):
453
+ """Copy text to clipboard.
454
+
455
+ Args:
456
+ text: String to copy
457
+
458
+ Returns:
459
+ tuple: (success: bool, error_message: str or None)
460
+ """
461
+ cls._probe()
462
+ import subprocess
463
+ import base64
464
+
465
+ # Try OSC 52 escape sequence (works over SSH with supporting terminals)
466
+ if cls._use_osc52:
467
+ try:
468
+ encoded = base64.b64encode(text.encode('utf-8')).decode('ascii')
469
+ # OSC 52: \033]52;c;<base64>\a
470
+ # 'c' = clipboard selection
471
+ osc52 = f'\033]52;c;{encoded}\a'
472
+ sys.stdout.write(osc52)
473
+ sys.stdout.flush()
474
+ return True, None
475
+ except Exception as e:
476
+ return False, f"OSC 52 failed: {e}"
477
+
478
+ if not cls._clipboard_cmd:
479
+ return False, "No clipboard available"
480
+
481
+ try:
482
+ proc = subprocess.run(
483
+ cls._clipboard_cmd,
484
+ input=text.encode('utf-8'),
485
+ capture_output=True,
486
+ timeout=5
487
+ )
488
+ if proc.returncode == 0:
489
+ return True, None
490
+ return False, proc.stderr.decode('utf-8', errors='replace').strip()
491
+ except subprocess.TimeoutExpired:
492
+ return False, "Clipboard command timed out"
493
+ except Exception as e:
494
+ return False, str(e)
dwipe/VerifyTask.py CHANGED
@@ -310,7 +310,9 @@ class VerifyTask(WipeTask):
310
310
  def get_status(self):
311
311
  """Get current progress status (thread-safe, called from main thread)
312
312
 
313
- Returns verification percentage with 'v' prefix (e.g., "v45%")
313
+ Returns:
314
+ tuple: (elapsed_str, pct_str, rate_str, eta_str, more_state)
315
+ - pct_str has 'v' prefix (e.g., "v45%")
314
316
  """
315
317
  mono = time.monotonic()
316
318
  elapsed_time = mono - self.start_mono
@@ -347,7 +349,7 @@ class VerifyTask(WipeTask):
347
349
  else:
348
350
  when_str = '0'
349
351
 
350
- return Utils.ago_str(int(round(elapsed_time))), pct_str, rate_str, when_str
352
+ return Utils.ago_str(int(round(elapsed_time))), pct_str, rate_str, when_str, self.more_state
351
353
 
352
354
  def get_summary_dict(self):
353
355
  """Get final summary for this verify task
dwipe/WipeJob.py CHANGED
@@ -18,6 +18,7 @@ from .Utils import Utils
18
18
  from .WipeTask import WipeTask
19
19
  from .WriteTask import WriteTask, WriteZeroTask, WriteRandTask
20
20
  from .VerifyTask import VerifyTask, VerifyZeroTask, VerifyRandTask
21
+ from .FirmwareWipeTask import FirmwareWipeTask
21
22
 
22
23
 
23
24
  class WipeJob:
@@ -166,7 +167,7 @@ class WipeJob:
166
167
 
167
168
  ## SLOWDOWN / STALL DETECTION/ABORT FEATURE (proxied from tasks)
168
169
  ##
169
- self.slowdown_stop = getattr(opts, 'slowdown_stop', 16)
170
+ self.slowdown_stop = getattr(opts, 'slowdown_stop', 64)
170
171
  self.stall_timeout = getattr(opts, 'stall_timeout', 60)
171
172
  self.max_slowdown_ratio = 0
172
173
  self.max_stall_secs = 0
@@ -230,6 +231,9 @@ class WipeJob:
230
231
  self.current_task = task
231
232
  self.current_task_index = i
232
233
 
234
+ # Set job reference so tasks can access shared state (e.g., for firmware verify)
235
+ task.job = self
236
+
233
237
  # Run the task
234
238
  task.run_task()
235
239
 
@@ -264,17 +268,22 @@ class WipeJob:
264
268
  summary = task.get_summary_dict()
265
269
  self.verify_result = summary.get('result', None)
266
270
 
271
+ # Write marker with verify status after verification completes
272
+ if not task.exception and not self.do_abort and self.verify_result:
273
+ is_random = (self.expected_pattern == "random")
274
+ self._write_marker_with_verify_status(is_random)
275
+
267
276
  # Check for task errors (AFTER proxying state)
268
277
  if task.exception:
269
- self.exception = task.exception
270
- # For write tasks, failure means wipe didn't succeed
271
- if isinstance(task, WriteTask):
278
+ # For write tasks and firmware wipe tasks, failure is critical - set exception and break
279
+ if isinstance(task, (WriteTask, FirmwareWipeTask)):
280
+ self.exception = task.exception
272
281
  # Sync abort state before breaking
273
282
  if task.do_abort:
274
283
  self.do_abort = True
275
284
  break
276
- # For verify tasks, continue but record the exception
277
- # (wipe succeeded but verification failed)
285
+ # For other tasks (precheck, pre-verify, post-verify), continue without setting job exception
286
+ # (these are support tasks - only the actual wipe matters for job success/failure)
278
287
 
279
288
  # Check if task was aborted (sync abort state)
280
289
  if task.do_abort and not self.do_abort:
@@ -588,12 +597,13 @@ class WipeJob:
588
597
  f"Completed: {percent_complete:.2f}%")
589
598
 
590
599
  def get_status(self):
591
- """Get status tuple: (elapsed, percent, rate, eta)
600
+ """Get status tuple: (elapsed, percent, rate, eta, more_state)
592
601
 
593
602
  Returns stats for current phase only:
594
603
  - Write phase (0-100%): elapsed/rate/eta for writing
595
604
  - Flushing phase: 100% FLUSH while kernel syncs to device
596
605
  - Verify phase (v0-v100%): elapsed/rate/eta for verification only
606
+ - more_state: optional extra status from derived task class
597
607
  """
598
608
  # NEW: Proxy to current task if using task-based architecture
599
609
  if self.current_task is not None:
@@ -646,7 +656,7 @@ class WipeJob:
646
656
  else:
647
657
  when_str = '0'
648
658
 
649
- return Utils.ago_str(int(round(elapsed_time))), pct_str, rate_str, when_str
659
+ return Utils.ago_str(int(round(elapsed_time))), pct_str, rate_str, when_str, ""
650
660
  else:
651
661
  # Write phase: 0-100% (across all passes)
652
662
  written = self.total_written
@@ -683,7 +693,7 @@ class WipeJob:
683
693
  else:
684
694
  when_str = '0'
685
695
 
686
- return Utils.ago_str(int(round(elapsed_time))), pct_str, rate_str, when_str
696
+ return Utils.ago_str(int(round(elapsed_time))), pct_str, rate_str, when_str, ""
687
697
 
688
698
  def get_plan_dict(self, mode=None):
689
699
  """Generate plan dictionary for structured logging
@@ -763,7 +773,7 @@ class WipeJob:
763
773
 
764
774
  # Build top-level summary
765
775
  summary = {
766
- "result": "stopped" if self.do_abort else "completed",
776
+ "result": "stopped" if self.do_abort else ("failed" if self.exception else "completed"),
767
777
  "total_elapsed": Utils.ago_str(int(total_elapsed)),
768
778
  "total_errors": total_errors,
769
779
  "pct_complete": round(pct_complete, 1),
@@ -850,7 +860,7 @@ class WipeJob:
850
860
 
851
861
  # Build top-level summary
852
862
  summary = {
853
- "result": "stopped" if self.do_abort else "completed",
863
+ "result": "stopped" if self.do_abort else ("failed" if self.exception else "completed"),
854
864
  "total_elapsed": Utils.ago_str(int(total_elapsed)),
855
865
  "total_errors": self.total_errors,
856
866
  "pct_complete": round(pct_complete, 1),
@@ -1223,8 +1233,10 @@ class WipeJob:
1223
1233
  # Auto-start verification if enabled and write completed successfully
1224
1234
  verify_pct = getattr(self.opts, 'verify_pct', 0)
1225
1235
  auto_verify = getattr(self.opts, 'wipe_mode', "").endswith('+V')
1226
- if auto_verify and verify_pct > 0 and not self.do_abort and not self.exception:
1227
- self.verify_partition(verify_pct)
1236
+ if auto_verify and not self.do_abort and not self.exception:
1237
+ # Default to 2% verification if +V mode enabled but verify_pct not set
1238
+ actual_verify_pct = verify_pct if verify_pct > 0 else 2
1239
+ self.verify_partition(actual_verify_pct)
1228
1240
  # Write marker with verification status after verification completes
1229
1241
  # Use desired_mode to determine if random or zero
1230
1242
  is_random = (desired_mode == 'Rand')
dwipe/WipeTask.py CHANGED
@@ -57,6 +57,7 @@ class WipeTask:
57
57
  self.do_abort = False
58
58
  self.done = False
59
59
  self.exception = None
60
+ self.more_state = "" # Optional extra status info from derived classes
60
61
 
61
62
  # Progress tracking
62
63
  self.total_written = 0 # Bytes processed (write or verify)
@@ -80,11 +81,12 @@ class WipeTask:
80
81
  """Get current progress status (thread-safe, called from main thread)
81
82
 
82
83
  Returns:
83
- tuple: (elapsed_str, pct_str, rate_str, eta_str)
84
+ tuple: (elapsed_str, pct_str, rate_str, eta_str, more_state)
84
85
  - elapsed_str: e.g., "5m23s"
85
86
  - pct_str: e.g., "45%" or "v23%" (for verify)
86
87
  - rate_str: e.g., "450MB/s"
87
88
  - eta_str: e.g., "2m15s"
89
+ - more_state: optional extra status from derived class
88
90
  """
89
91
  mono = time.monotonic()
90
92
  elapsed_time = mono - self.start_mono
@@ -116,7 +118,7 @@ class WipeTask:
116
118
  else:
117
119
  when_str = '0'
118
120
 
119
- return Utils.ago_str(int(round(elapsed_time))), pct_str, rate_str, when_str
121
+ return Utils.ago_str(int(round(elapsed_time))), pct_str, rate_str, when_str, self.more_state
120
122
 
121
123
  def get_summary_dict(self):
122
124
  """Get final summary after task completion
dwipe/WriteTask.py CHANGED
@@ -54,7 +54,7 @@ class WriteTask(WipeTask):
54
54
  self.marker_update_interval = 30 # Update every 30 seconds
55
55
 
56
56
  # Performance monitoring
57
- self.slowdown_stop = getattr(opts, 'slowdown_stop', 16)
57
+ self.slowdown_stop = getattr(opts, 'slowdown_stop', 64)
58
58
  self.stall_timeout = getattr(opts, 'stall_timeout', 60)
59
59
  self.max_slowdown_ratio = 0
60
60
  self.max_stall_secs = 0
dwipe/main.py CHANGED
@@ -16,18 +16,41 @@ from .DiskWipe import DiskWipe
16
16
  from .DeviceInfo import DeviceInfo
17
17
  from .Utils import Utils
18
18
  from .Prereqs import Prereqs
19
+ from .PersistentState import PersistentState
19
20
 
20
21
 
21
22
  def main():
22
23
  """Main entry point"""
23
24
  import argparse
25
+ from pathlib import Path
26
+
27
+ # Load persistent state first so we can use previous values as defaults
28
+ config_dir = Utils.get_config_dir()
29
+ config_path = config_dir / 'state.json'
30
+ persist = PersistentState(config_path=config_path)
31
+
24
32
  parser = argparse.ArgumentParser()
25
33
  parser.add_argument('--dump-lsblk', action='store_true',
26
34
  help='dump parsed lsblk and exit for debugging')
27
- parser.add_argument('-F', '--firmware-wipes', action='store_true',
28
- help='enable experimental (alpha) firmware wipes')
35
+ parser.add_argument('--mode', choices=['-V', '+V'], default=persist.state['wipe_mode'],
36
+ help='verification mode: -V (none) or +V (verify after wipe)')
37
+ parser.add_argument('--passes', type=int, choices=[1, 2, 4], default=persist.state['passes'],
38
+ help='number of passes for software wipes (1, 2, or 4)')
39
+ parser.add_argument('--verify-pct', type=int, choices=[1, 3, 10, 30, 100], default=persist.state['verify_pct'],
40
+ help='verification percentage after wipe (1, 3, 10, 30, or 100)')
41
+ parser.add_argument('--port-serial', choices=['Auto', 'On', 'Off'], default=persist.state['port_serial'],
42
+ help='display mode for disk port/serial info (Auto, On, or Off)')
43
+ parser.add_argument('--slowdown-stop', type=int, choices=[0, 4, 16, 64, 256], default=persist.state['slowdown_stop'],
44
+ help='stop wipe if disk slows down (0 = disabled, else check interval in ms)')
45
+ parser.add_argument('--stall-timeout', type=int, choices=[0, 60, 120, 300, 600], default=persist.state['stall_timeout'],
46
+ help='stall timeout in seconds (0 = disabled)')
47
+ parser.add_argument('--dense', choices=['True', 'False'], default='True' if persist.state['dense'] else 'False',
48
+ help='dense view: True (compact) or False (spaced lines)')
29
49
  opts = parser.parse_args()
30
50
 
51
+ # Convert string booleans to actual booleans
52
+ opts.dense = opts.dense == 'True'
53
+
31
54
  dwipe = None # Initialize to None so exception handler can reference it
32
55
  try:
33
56
  if os.geteuid() != 0:
@@ -35,14 +58,11 @@ def main():
35
58
  Utils.rerun_module_as_root('dwipe.main')
36
59
 
37
60
  prereqs = Prereqs(verbose=True)
38
- # lsblk is critical for everything; hdparm and nvme only needed for firmware wipes
39
- if opts.firmware_wipes:
40
- prereqs.check_all(['lsblk', 'hdparm', 'nvme'])
41
- else:
42
- prereqs.check_all(['lsblk'])
61
+ # blkid for filesystem detection; hdparm and nvme for firmware wipes
62
+ prereqs.check_all(['blkid', 'hdparm', 'nvme'])
43
63
  prereqs.report_and_exit_if_failed()
44
64
 
45
- dwipe = DiskWipe(opts=opts) # opts=opts)
65
+ dwipe = DiskWipe(opts=opts, persistent_state=persist)
46
66
 
47
67
  dwipe.main_loop()
48
68
  except Exception as exce: