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/DeviceChangeMonitor.py +244 -0
- dwipe/DeviceInfo.py +589 -194
- dwipe/DeviceWorker.py +566 -0
- dwipe/DiskWipe.py +558 -134
- dwipe/DrivePreChecker.py +161 -48
- dwipe/FirmwareWipeTask.py +627 -132
- dwipe/NvmeTool.py +225 -0
- dwipe/PersistentState.py +20 -9
- dwipe/SataTool.py +499 -0
- dwipe/StructuredLogger.py +4 -3
- dwipe/Tunables.py +62 -0
- dwipe/Utils.py +192 -5
- dwipe/VerifyTask.py +4 -2
- dwipe/WipeJob.py +25 -13
- dwipe/WipeTask.py +4 -2
- dwipe/WriteTask.py +1 -1
- dwipe/main.py +28 -8
- {dwipe-2.0.2.dist-info → dwipe-3.0.0.dist-info}/METADATA +219 -99
- dwipe-3.0.0.dist-info/RECORD +24 -0
- dwipe/LsblkMonitor.py +0 -124
- dwipe/ToolManager.py +0 -618
- dwipe-2.0.2.dist-info/RECORD +0 -21
- {dwipe-2.0.2.dist-info → dwipe-3.0.0.dist-info}/WHEEL +0 -0
- {dwipe-2.0.2.dist-info → dwipe-3.0.0.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.2.dist-info → dwipe-3.0.0.dist-info}/licenses/LICENSE +0 -0
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', # '
|
|
28
|
-
'passes': 1, # 1, 2, or 4 wipe
|
|
29
|
-
'
|
|
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':
|
|
33
|
-
'stall_timeout': 60,
|
|
34
|
-
'port_serial':
|
|
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
|
-
|
|
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')
|