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/NvmeTool.py ADDED
@@ -0,0 +1,225 @@
1
+ import os
2
+ import json
3
+ import subprocess
4
+ import io
5
+ import re
6
+ import time
7
+ from types import SimpleNamespace
8
+ from .Utils import Utils
9
+
10
+
11
+ class NvmeTool:
12
+ """NVMe equivalent of SataTool using nvme-cli."""
13
+
14
+ def __init__(self, device_name, timeout=10):
15
+ self.timeout = timeout
16
+ # Handle /dev/nvme0n1 or nvme0n1
17
+ if device_name.startswith('/dev/'):
18
+ self.device_name = device_name[len('/dev/'):]
19
+ self.device_path = device_name
20
+ else:
21
+ self.device_name = device_name
22
+ self.device_path = f'/dev/{self.device_name}'
23
+
24
+ self.caps = None # Stores parsed sanitize/format capabilities
25
+ self.job = SimpleNamespace()
26
+ self.job.process = None
27
+ self.job.wipe_started_mono = None
28
+ self.job.est_secs = 60 # NVMe is usually much faster than SATA
29
+ self.last_command = None # Store the last command executed
30
+
31
+ def run_nvme_cmd(self, args, json_out=True):
32
+ """Helper to run nvme-cli commands."""
33
+ cmd = ['nvme'] + args
34
+ if json_out:
35
+ cmd += ['-o', 'json']
36
+
37
+ try:
38
+ res = subprocess.run(cmd, capture_output=True, text=True, timeout=self.timeout)
39
+ if json_out and res.stdout:
40
+ return json.loads(res.stdout)
41
+ return res
42
+ except Exception as e:
43
+ return None
44
+
45
+ def get_supported_lbaf(self):
46
+ """Get a supported LBA format index.
47
+
48
+ Queries the namespace to find supported LBA formats and returns
49
+ one that should work. Returns the currently active format as first choice.
50
+
51
+ Returns:
52
+ int: LBAF index (0-15)
53
+ """
54
+ try:
55
+ # Query namespace
56
+ data = self.run_nvme_cmd(['id-ns', self.device_path])
57
+ if data:
58
+ # Get currently active format
59
+ flbas = data.get('flbas', 0)
60
+ if isinstance(flbas, dict):
61
+ current_lbaf = flbas.get('format', 0)
62
+ else:
63
+ current_lbaf = int(flbas) & 0x0F
64
+
65
+ # Try to get list of supported formats
66
+ lbafs = data.get('lbafs', [])
67
+ if lbafs and isinstance(lbafs, list):
68
+ # Check if current format is in supported list
69
+ if current_lbaf < len(lbafs):
70
+ return current_lbaf
71
+ # Otherwise return first supported format
72
+ if len(lbafs) > 0:
73
+ return 0
74
+
75
+ return current_lbaf
76
+ except Exception:
77
+ pass
78
+ return 0 # Default to format 0
79
+
80
+ def refresh_capabilities(self):
81
+ """
82
+ Detects if Sanitize and Format (Crypto/Erase) are supported.
83
+ NVMe Sanitize: Check 'id-ctrl' for 'sanicap'
84
+ NVMe Format: Check 'id-ctrl' for 'fna'
85
+ """
86
+ data = self.run_nvme_cmd(['id-ctrl', self.device_path])
87
+ caps = SimpleNamespace(
88
+ has_sanitize=False,
89
+ crypto_erase_supported=False,
90
+ block_erase_supported=False,
91
+ overwrite_supported=False,
92
+ format_supported=False,
93
+ format_crypto_supported=False,
94
+ raw_data=data
95
+ )
96
+
97
+ if data:
98
+ # Sanitize Capabilities (sanicap)
99
+ has_san, crypt, block, ovwr = Utils.parse_nvme_sanitize_flags(data)
100
+ caps.has_sanitize = has_san
101
+ caps.crypto_erase_supported = crypt
102
+ caps.block_erase_supported = block
103
+ caps.overwrite_supported = ovwr
104
+
105
+ # Optional NVM Command Support (oncs)
106
+ # Bit 2: Format NVM command is supported
107
+ oncs = data.get('oncs', 0)
108
+ caps.format_supported = bool(oncs & 0x04)
109
+
110
+ # Format Capabilities (fna)
111
+ # Bit 2 indicates if Crypto Erase is supported via Format command
112
+ fna = data.get('fna', 0)
113
+ caps.format_crypto_supported = bool(fna & 0x04) and caps.format_supported
114
+
115
+ self.caps = caps
116
+ return caps
117
+
118
+ def get_wipe_verdict(self, method='sanitize_block'):
119
+ """Determines if the drive can be wiped with the specified method.
120
+
121
+ Args:
122
+ method: Wipe method ('sanitize_block', 'sanitize_crypto', 'sanitize_overwrite',
123
+ 'format_erase', 'format_crypto')
124
+
125
+ Returns:
126
+ 'OK' if supported, or error reason string
127
+ """
128
+ if not self.caps:
129
+ self.refresh_capabilities()
130
+
131
+ # Check if the specific method is supported
132
+ if method == 'sanitize_block':
133
+ if not self.caps.block_erase_supported:
134
+ return "Block Erase not supported"
135
+ elif method == 'sanitize_crypto':
136
+ if not self.caps.crypto_erase_supported:
137
+ return "Crypto Erase not supported"
138
+ elif method == 'sanitize_overwrite':
139
+ if not self.caps.overwrite_supported:
140
+ return "Overwrite not supported"
141
+ elif method in ('format_erase', 'format_crypto'):
142
+ if not self.caps.format_supported:
143
+ return "Format not supported"
144
+ else:
145
+ return f"Unknown method: {method}"
146
+
147
+ # NVMe drives don't 'freeze' like SATA, but they can be Read-Only
148
+ # or have Namespace management locks.
149
+ return "OK"
150
+
151
+
152
+
153
+ def start_wipe(self, method='format_crypto'):
154
+ ctrl_path = re.sub(r'n\d+$', '', self.device_path)
155
+
156
+ # 1. Determine the Best Command
157
+ # If the drive supports Sanitize, it's MUCH more 'general' than Format
158
+ if method.startswith('sanitize'):
159
+ mode_map = {'sanitize_block': '2',
160
+ 'sanitize_crypto': '4',
161
+ 'sanitize_overwrite': '3'}
162
+ cmd = ['nvme', 'sanitize', '-a', mode_map[method], self.device_path]
163
+ else:
164
+ # For 'Format', we send the BARE MINIMUM.
165
+ # No --lbaf, no --ms, no --pi.
166
+ # This forces the drive to use its current valid hardware configuration.
167
+ ses_type = '2' if method == 'format_crypto' else '1'
168
+ cmd = [
169
+ 'nvme', 'format', ctrl_path,
170
+ '--namespace-id=0xffffffff', # Target all namespaces (prevents locks)
171
+ f'--ses={ses_type}',
172
+ '--force'
173
+ ]
174
+
175
+ # Store command for logging in task summary
176
+ self.last_command = cmd
177
+
178
+ try:
179
+ # We run synchronously for the format command because it's fast
180
+ # and we need to check for that 0x410a immediately.
181
+ res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
182
+
183
+ # FALLBACK: If 0xffffffff (all namespaces) is rejected, try just the specific one
184
+ if res.returncode != 0 and '0x410a' in (res.stdout + res.stderr):
185
+ nsid_match = re.search(r'n(\d+)$', self.device_path)
186
+ nsid = nsid_match.group(1) if nsid_match else "1"
187
+ cmd[3] = f'--namespace-id={nsid}'
188
+ res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
189
+
190
+ # 2. Wrap the result to fix your 'AttributeError'
191
+ # We use io.StringIO so that .read() works in your Task manager
192
+ self.job.process = SimpleNamespace(
193
+ poll=lambda: 0, # Marks it as finished
194
+ returncode=res.returncode,
195
+ stdout=io.StringIO(res.stdout),
196
+ stderr=io.StringIO(res.stderr),
197
+ communicate=lambda: (res.stdout, res.stderr)
198
+ )
199
+ self.job.wipe_started_mono = time.monotonic()
200
+ return self.job.process
201
+
202
+ except Exception as e:
203
+ self.job.process = SimpleNamespace(
204
+ poll=lambda: 1,
205
+ returncode=1,
206
+ stdout=io.StringIO(""),
207
+ stderr=io.StringIO(str(e)),
208
+ communicate=lambda: ("", str(e))
209
+ )
210
+ return self.job.process
211
+
212
+ def get_sanitize_status(self):
213
+ """
214
+ NVMe Sanitize runs in the background.
215
+ We must poll 'sanitize-log' to see if it's actually done.
216
+ """
217
+ log = self.run_nvme_cmd(['sanitize-log', self.device_path])
218
+ if log:
219
+ # sstat: 0=Idle, 1=In Progress, 2=Success
220
+ status = log.get('sstat', 0) & 0x7
221
+ progress = log.get('sprog', 0) # 65535 = 100%
222
+ percent = (progress / 65535.0) * 100
223
+ return status, percent
224
+ return None, 0
225
+
dwipe/PersistentState.py CHANGED
@@ -9,7 +9,7 @@ from .Utils import Utils
9
9
 
10
10
 
11
11
  class PersistentState:
12
- """Manages persistent state for dwipe preferences and device locks"""
12
+ """Manages persistent state for dwipe preferences and device blocks"""
13
13
 
14
14
  def __init__(self, config_path=None):
15
15
  """Initialize persistent state
@@ -24,15 +24,14 @@ class PersistentState:
24
24
  self.config_path = Path(config_path)
25
25
  self.state = {
26
26
  'theme': 'default',
27
- 'wipe_mode': 'Zero', # 'Rand' or 'Zero' or +V
28
- 'passes': 1, # 1, 2, or 4 wipe pass
29
- 'confirmation': 'YES', # 'Y', 'y', 'YES', 'yes', 'device'
30
- 'verify_pct': 2, # 0, 2, 5, 10, 25, 50, 100
27
+ 'wipe_mode': '+V', # '-V' (no verify) or '+V' (verify after wipe) [default: +V]
28
+ 'passes': 1, # 1, 2, or 4 wipe passes [default: 1]
29
+ 'verify_pct': 1, # Verification percentage: 1, 3, 10, 30, 100 [default: 1]
31
30
  'dense': False, # True = compact view, False = blank lines between disks
32
- 'slowdown_stop': 16,
33
- 'stall_timeout': 60,
34
- 'port_serial': False,
35
- 'devices': {} # device_id -> {locked, last_seen, last_name, size_bytes}
31
+ 'slowdown_stop': 64, # Stop if disk slows (0=disabled, else ms interval) [default: 64]
32
+ 'stall_timeout': 60, # Stall timeout in seconds (0=disabled) [default: 60]
33
+ 'port_serial': 'Auto', # Show port/serial info: Auto, On, Off [default: Auto]
34
+ 'devices': {} # device_id -> {blocked, last_seen, last_name, size_bytes}
36
35
  }
37
36
  self.dirty = False
38
37
  self.max_devices = 400
@@ -57,12 +56,36 @@ class PersistentState:
57
56
  setattr(opts, key, self.state[key])
58
57
 
59
58
  def load(self):
60
- """Load state from disk"""
59
+ """Load state from disk, handling schema upgrades/downgrades"""
61
60
  if self.config_path.exists():
62
61
  try:
62
+ # Remember valid keys from default state
63
+ valid_keys = set(self.state.keys())
64
+
63
65
  with open(self.config_path, 'r', encoding='utf-8') as f:
64
66
  loaded = json.load(f)
65
- self.state.update(loaded)
67
+
68
+ # Only load keys that exist in current schema
69
+ for key in valid_keys:
70
+ if key in loaded:
71
+ self.state[key] = loaded[key]
72
+
73
+ # Check for obsolete keys that need removal
74
+ obsolete_keys = set(loaded.keys()) - valid_keys
75
+ if obsolete_keys:
76
+ self.dirty = True # Trigger save to clean up
77
+
78
+ # Migrate old wipe_mode values to new format
79
+ old_wipe_mode = self.state.get('wipe_mode', '+V')
80
+ if old_wipe_mode not in ['+V', '-V']:
81
+ # Old format: 'Zero', 'Zero+V', 'Rand', 'Rand+V'
82
+ # Convert to new format: '+V' or '-V'
83
+ if '+V' in str(old_wipe_mode):
84
+ self.state['wipe_mode'] = '+V'
85
+ else:
86
+ self.state['wipe_mode'] = '-V'
87
+ self.dirty = True # Save the migration
88
+
66
89
  except (json.JSONDecodeError, IOError) as e:
67
90
  print(f'Warning: Could not load state from {self.config_path}: {e}')
68
91
 
@@ -134,24 +157,28 @@ class PersistentState:
134
157
  return f'fallback:{fallback_id}'
135
158
 
136
159
  def get_device_locked(self, partition):
137
- """Check if a device is locked
160
+ """Check if a device is blocked (backward compatible with 'locked')
138
161
 
139
162
  Args:
140
163
  partition: SimpleNamespace with device info
141
164
 
142
165
  Returns:
143
- bool: True if device is locked
166
+ bool: True if device is blocked
144
167
  """
145
168
  device_id = self.make_device_id(partition)
146
169
  device_state = self.state['devices'].get(device_id, {})
170
+
171
+ # Check new 'blocked' field first, fall back to old 'locked' field for backward compatibility
172
+ if 'blocked' in device_state:
173
+ return device_state['blocked']
147
174
  return device_state.get('locked', False)
148
175
 
149
176
  def set_device_locked(self, partition, locked):
150
- """Set device lock state
177
+ """Set device block state
151
178
 
152
179
  Args:
153
180
  partition: SimpleNamespace with device info
154
- locked: bool, True to lock device
181
+ locked: bool, True to block device (parameter name kept for API compatibility)
155
182
  """
156
183
  device_id = self.make_device_id(partition)
157
184
  now = int(time.time())
@@ -160,7 +187,9 @@ class PersistentState:
160
187
  self.state['devices'][device_id] = {}
161
188
 
162
189
  device_state = self.state['devices'][device_id]
163
- device_state['locked'] = locked
190
+ device_state['blocked'] = locked # Only save 'blocked', not 'locked'
191
+ # Remove old 'locked' field if it exists (gradual migration)
192
+ device_state.pop('locked', None)
164
193
  device_state['last_seen'] = now
165
194
  device_state['last_name'] = partition.name
166
195
  device_state['size_bytes'] = partition.size_bytes
dwipe/Prereqs.py ADDED
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ import shutil
3
+ import sys
4
+ import os
5
+ from typing import Optional, Dict, List
6
+
7
+ class Prereqs:
8
+ """Manages tool dependencies and provides actionable install suggestions."""
9
+
10
+ # Mapping tool binary to the actual package name for different managers
11
+ TOOL_MAP = {
12
+ 'lsblk': {
13
+ 'apt': 'util-linux', 'dnf': 'util-linux', 'pacman': 'util-linux',
14
+ 'zypper': 'util-linux', 'apk': 'util-linux'
15
+ },
16
+ 'hdparm': {
17
+ 'apt': 'hdparm', 'dnf': 'hdparm', 'pacman': 'hdparm',
18
+ 'zypper': 'hdparm', 'apk': 'hdparm'
19
+ },
20
+ 'nvme': {
21
+ 'apt': 'nvme-cli', 'dnf': 'nvme-cli', 'pacman': 'nvme-cli',
22
+ 'zypper': 'nvme-cli', 'apk': 'nvme-cli'
23
+ }
24
+ }
25
+
26
+ def __init__(self, verbose: bool = False):
27
+ self.verbose = verbose
28
+ self.pm = self._detect_package_manager()
29
+ # Track status of required tools
30
+ self.results = {}
31
+
32
+ def _detect_package_manager(self) -> Optional[str]:
33
+ managers = ['apt', 'dnf', 'yum', 'pacman', 'zypper', 'apk', 'brew']
34
+ for cmd in managers:
35
+ if shutil.which(cmd):
36
+ return cmd
37
+ return None
38
+
39
+ def check_all(self, tools: List[str]):
40
+ """Runs the check for a list of tools."""
41
+ for tool in tools:
42
+ path = shutil.which(tool)
43
+ self.results[tool] = path is not None
44
+ return all(self.results.values())
45
+
46
+ def get_install_hint(self, tool: str) -> str:
47
+ """Returns a string suggesting how to fix the missing tool."""
48
+ if not self.pm:
49
+ return "Please install via your system's package manager."
50
+
51
+ # Get package name (default to tool name if mapping missing)
52
+ pkg = self.TOOL_MAP.get(tool, {}).get(self.pm, tool)
53
+
54
+ commands = {
55
+ 'apt': f"sudo apt update && sudo apt install {pkg}",
56
+ 'dnf': f"sudo dnf install {pkg}",
57
+ 'yum': f"sudo yum install {pkg}",
58
+ 'pacman': f"sudo pacman -S {pkg}",
59
+ 'zypper': f"sudo zypper install {pkg}",
60
+ 'apk': f"apk add {pkg}",
61
+ 'brew': f"brew install {pkg}"
62
+ }
63
+ return commands.get(self.pm, f"Use {self.pm} to install {pkg}")
64
+
65
+ def report_and_exit_if_failed(self):
66
+ """Prints a clean summary to stdout. Exits if critical tools are missing."""
67
+ print("\n--- System Prerequisite Check ---")
68
+ failed = False
69
+
70
+ for tool, available in self.results.items():
71
+ mark = "✓" if available else "✗"
72
+ status = "FOUND" if available else "MISSING"
73
+ print(f" {mark} {tool:<10} : {status}")
74
+
75
+ if not available:
76
+ failed = True
77
+ print(f" └─ Suggestion: {self.get_install_hint(tool)}")
78
+
79
+ if failed:
80
+ print("\nERROR: Dwipe cannot start until all prerequisites are met.\n")
81
+ sys.exit(1)
82
+
83
+ if self.verbose:
84
+ print("All prerequisites satisfied.\n")