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/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
@@ -24,14 +24,13 @@ class PersistentState:
24
24
  self.config_path = Path(config_path)
25
25
  self.state = {
26
26
  'theme': 'default',
27
- 'wipe_mode': '+V', # '+V' (verify) or '-V' (no verify)
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,
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]
35
34
  'devices': {} # device_id -> {blocked, last_seen, last_name, size_bytes}
36
35
  }
37
36
  self.dirty = False
@@ -57,12 +56,24 @@ 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
66
77
 
67
78
  # Migrate old wipe_mode values to new format
68
79
  old_wipe_mode = self.state.get('wipe_mode', '+V')