dwipe 2.0.1__py3-none-any.whl → 2.0.2__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/DeviceInfo.py +183 -52
- dwipe/DiskWipe.py +495 -180
- dwipe/DrivePreChecker.py +90 -0
- dwipe/FirmwareWipeTask.py +370 -0
- dwipe/LsblkMonitor.py +124 -0
- dwipe/PersistentState.py +26 -8
- dwipe/Prereqs.py +84 -0
- dwipe/StructuredLogger.py +643 -0
- dwipe/ToolManager.py +235 -254
- dwipe/Utils.py +108 -0
- dwipe/VerifyTask.py +410 -0
- dwipe/WipeJob.py +613 -165
- dwipe/WipeTask.py +148 -0
- dwipe/WriteTask.py +402 -0
- dwipe/main.py +14 -9
- {dwipe-2.0.1.dist-info → dwipe-2.0.2.dist-info}/METADATA +69 -33
- dwipe-2.0.2.dist-info/RECORD +21 -0
- dwipe/WipeJobFuture.py +0 -245
- dwipe-2.0.1.dist-info/RECORD +0 -14
- {dwipe-2.0.1.dist-info → dwipe-2.0.2.dist-info}/WHEEL +0 -0
- {dwipe-2.0.1.dist-info → dwipe-2.0.2.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.1.dist-info → dwipe-2.0.2.dist-info}/licenses/LICENSE +0 -0
dwipe/DrivePreChecker.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import subprocess
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class PreCheckResult:
|
|
10
|
+
# Key = Short Code (Frozen, Locked), Value = Long Description
|
|
11
|
+
issues: Dict[str, str] = field(default_factory=dict)
|
|
12
|
+
modes: Dict[str, str] = field(default_factory=dict)
|
|
13
|
+
|
|
14
|
+
class DrivePreChecker:
|
|
15
|
+
def __init__(self, timeout: int = 10):
|
|
16
|
+
self.timeout = timeout
|
|
17
|
+
|
|
18
|
+
def check_nvme_drive(self, device: str) -> PreCheckResult:
|
|
19
|
+
result = PreCheckResult()
|
|
20
|
+
try:
|
|
21
|
+
id_ctrl = subprocess.run(
|
|
22
|
+
['nvme', 'id-ctrl', device, '-o', 'json'],
|
|
23
|
+
check=False, capture_output=True, text=True, timeout=self.timeout
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if id_ctrl.returncode != 0:
|
|
27
|
+
result.issues['Unresponsive'] = "NVMe controller did not respond to id-ctrl"
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
data = json.loads(id_ctrl.stdout)
|
|
31
|
+
|
|
32
|
+
# 1. Sanitize Support
|
|
33
|
+
sanicap = data.get('sanicap', 0)
|
|
34
|
+
if sanicap > 0:
|
|
35
|
+
if sanicap & 0x04: result.modes['CryptoNv'] = 'sanitize --action=0x04'
|
|
36
|
+
if sanicap & 0x02: result.modes['BlockNv'] = 'sanitize --action=0x02'
|
|
37
|
+
if sanicap & 0x08: result.modes['OvwrNv'] = 'sanitize --action=0x03'
|
|
38
|
+
|
|
39
|
+
# 2. Format Support (Legacy)
|
|
40
|
+
if 'Format NVM' in id_ctrl.stdout:
|
|
41
|
+
fna = data.get('fna', 0)
|
|
42
|
+
if (fna >> 2) & 0x1:
|
|
43
|
+
result.modes['FmtCryptoNv'] = 'format --ses=2'
|
|
44
|
+
result.modes['FmtEraseNv'] = 'format --ses=1'
|
|
45
|
+
|
|
46
|
+
if not result.modes:
|
|
47
|
+
result.issues['Unsupported'] = "Drive lacks Sanitize or Format NVM capabilities"
|
|
48
|
+
|
|
49
|
+
except Exception as e:
|
|
50
|
+
result.issues['Error'] = f"NVMe Probe Exception: {str(e)}"
|
|
51
|
+
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
def check_ata_drive(self, device: str) -> PreCheckResult:
|
|
55
|
+
result = PreCheckResult()
|
|
56
|
+
try:
|
|
57
|
+
info = subprocess.run(
|
|
58
|
+
['hdparm', '-I', device],
|
|
59
|
+
check=False, capture_output=True, text=True, timeout=self.timeout
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if info.returncode != 0:
|
|
63
|
+
result.issues['Unresponsive'] = "Drive did not respond to hdparm"
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
out = info.stdout.lower()
|
|
67
|
+
|
|
68
|
+
# 1. Hardware Support Check
|
|
69
|
+
if "security erase unit" not in out:
|
|
70
|
+
result.issues['Unsupported'] = "Drive does not support ATA Security Erase"
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
# 2. Frozen Check
|
|
74
|
+
if "frozen" in out and "not frozen" not in out:
|
|
75
|
+
result.issues['Frozen'] = "Drive is FROZEN (BIOS/OS lock). Cycle power or Suspend/Resume."
|
|
76
|
+
|
|
77
|
+
# 3. Security Enabled (Password set)
|
|
78
|
+
if "enabled" in out and "not enabled" not in out:
|
|
79
|
+
result.issues['Locked'] = "Security is ENABLED (Drive is currently password locked)"
|
|
80
|
+
|
|
81
|
+
# 4. Populate Modes only if no fatal issues
|
|
82
|
+
if not result.issues:
|
|
83
|
+
if "enhanced erase" in out:
|
|
84
|
+
result.modes['EnhancedHd'] = '--user-master u --security-erase-enhanced NULL'
|
|
85
|
+
result.modes['EraseHd'] = '--user-master u --security-erase NULL'
|
|
86
|
+
|
|
87
|
+
except Exception as e:
|
|
88
|
+
result.issues['Error'] = f"ATA Probe Exception: {str(e)}"
|
|
89
|
+
|
|
90
|
+
return result
|
|
@@ -0,0 +1,370 @@
|
|
|
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
|
+
import os
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
import subprocess
|
|
14
|
+
import traceback
|
|
15
|
+
# from types import SimpleNamespace
|
|
16
|
+
|
|
17
|
+
from .WipeTask import WipeTask
|
|
18
|
+
from .Utils import Utils
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FirmwareWipeTask(WipeTask):
|
|
22
|
+
"""Abstract base class for firmware-based wipe operations
|
|
23
|
+
|
|
24
|
+
Firmware wipes execute in the drive's controller, not via CPU writes.
|
|
25
|
+
The host just sends a command and monitors for completion.
|
|
26
|
+
|
|
27
|
+
Progress reporting is estimated since most firmware doesn't report
|
|
28
|
+
real-time progress. NVMe sanitize can optionally poll for actual progress.
|
|
29
|
+
|
|
30
|
+
Subclasses must implement:
|
|
31
|
+
- _build_command(): Return list of command args
|
|
32
|
+
- _check_completion(): Check if wipe is complete
|
|
33
|
+
- _estimate_duration(): Estimate total time in seconds
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, device_path, total_size, opts, command_args, wipe_name):
|
|
37
|
+
"""Initialize firmware wipe task
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
device_path: Path to device (e.g., '/dev/sda', '/dev/nvme0n1')
|
|
41
|
+
total_size: Total size in bytes
|
|
42
|
+
opts: Options namespace
|
|
43
|
+
command_args: Command args from hw_caps (e.g., 'sanitize --action=0x04')
|
|
44
|
+
wipe_name: Human-readable name (e.g., 'Sanitize-Crypto')
|
|
45
|
+
"""
|
|
46
|
+
super().__init__(device_path, total_size, opts)
|
|
47
|
+
|
|
48
|
+
self.command_args = command_args
|
|
49
|
+
self.wipe_name = wipe_name
|
|
50
|
+
self.process = None
|
|
51
|
+
self.finish_mono = None
|
|
52
|
+
|
|
53
|
+
# Estimated duration for progress reporting
|
|
54
|
+
self.estimated_duration = self._estimate_duration()
|
|
55
|
+
|
|
56
|
+
def _estimate_duration(self):
|
|
57
|
+
"""Estimate total duration in seconds (override in subclasses)
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
int: Estimated seconds for completion
|
|
61
|
+
"""
|
|
62
|
+
return 60 # Default 1 minute
|
|
63
|
+
|
|
64
|
+
def get_display_name(self):
|
|
65
|
+
"""Get display name for firmware wipe"""
|
|
66
|
+
return self.wipe_name
|
|
67
|
+
|
|
68
|
+
def _build_command(self):
|
|
69
|
+
"""Build command list for subprocess (must be implemented by subclasses)
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
list: Command args like ['nvme', 'sanitize', ...]
|
|
73
|
+
"""
|
|
74
|
+
raise NotImplementedError("Subclasses must implement _build_command()")
|
|
75
|
+
|
|
76
|
+
def _check_completion(self):
|
|
77
|
+
"""Check if wipe completed successfully (can be overridden)
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
bool or None: True if done, False if failed, None if still running
|
|
81
|
+
"""
|
|
82
|
+
if self.process and self.process.poll() is not None:
|
|
83
|
+
return self.process.returncode == 0
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def _write_marker(self):
|
|
87
|
+
"""Write completion marker after firmware wipe
|
|
88
|
+
|
|
89
|
+
Firmware wipes erase the entire disk including any existing markers.
|
|
90
|
+
We need to write a new marker indicating the wipe is complete.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
# Force OS to re-read partition table (now empty)
|
|
94
|
+
subprocess.run(['blockdev', '--rereadpt', self.device_path],
|
|
95
|
+
capture_output=True, timeout=5, check=False)
|
|
96
|
+
time.sleep(1) # Let kernel settle
|
|
97
|
+
|
|
98
|
+
# Prepare marker data
|
|
99
|
+
data = {
|
|
100
|
+
"unixtime": int(time.time()),
|
|
101
|
+
"scrubbed_bytes": self.total_size,
|
|
102
|
+
"size_bytes": self.total_size,
|
|
103
|
+
"passes": 1,
|
|
104
|
+
"mode": self.wipe_name, # e.g., 'Sanitize-Crypto'
|
|
105
|
+
"firmware_wipe": True
|
|
106
|
+
}
|
|
107
|
+
json_data = json.dumps(data).encode('utf-8')
|
|
108
|
+
|
|
109
|
+
# Build marker buffer (16KB)
|
|
110
|
+
buffer = bytearray(WipeTask.MARKER_SIZE)
|
|
111
|
+
buffer[:WipeTask.STATE_OFFSET] = b'\x00' * WipeTask.STATE_OFFSET
|
|
112
|
+
buffer[WipeTask.STATE_OFFSET:WipeTask.STATE_OFFSET + len(json_data)] = json_data
|
|
113
|
+
remaining = WipeTask.MARKER_SIZE - (WipeTask.STATE_OFFSET + len(json_data))
|
|
114
|
+
buffer[WipeTask.STATE_OFFSET + len(json_data):] = b'\x00' * remaining
|
|
115
|
+
|
|
116
|
+
# Write marker to beginning of device
|
|
117
|
+
with open(self.device_path, 'wb') as f:
|
|
118
|
+
f.write(buffer)
|
|
119
|
+
f.flush()
|
|
120
|
+
os.fsync(f.fileno())
|
|
121
|
+
|
|
122
|
+
except Exception as e:
|
|
123
|
+
# Don't fail the whole job if marker write fails
|
|
124
|
+
self.exception = f"Marker write warning: {e}"
|
|
125
|
+
|
|
126
|
+
def run_task(self):
|
|
127
|
+
"""Execute firmware wipe operation (blocking, runs in thread)"""
|
|
128
|
+
try:
|
|
129
|
+
# Build command
|
|
130
|
+
cmd = self._build_command()
|
|
131
|
+
|
|
132
|
+
# Start subprocess (non-blocking)
|
|
133
|
+
self.process = subprocess.Popen(
|
|
134
|
+
cmd,
|
|
135
|
+
stdout=subprocess.PIPE,
|
|
136
|
+
stderr=subprocess.PIPE,
|
|
137
|
+
text=True
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Monitor progress with polling loop
|
|
141
|
+
check_interval = 2 # Check every 2 seconds
|
|
142
|
+
|
|
143
|
+
while not self.do_abort:
|
|
144
|
+
# Check if process completed
|
|
145
|
+
completion_status = self._check_completion()
|
|
146
|
+
|
|
147
|
+
if completion_status is True:
|
|
148
|
+
# Success!
|
|
149
|
+
self.total_written = self.total_size
|
|
150
|
+
self.finish_mono = time.monotonic()
|
|
151
|
+
|
|
152
|
+
# Write marker after successful firmware wipe
|
|
153
|
+
self._write_marker()
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
elif completion_status is False:
|
|
157
|
+
# Failed
|
|
158
|
+
stderr = self.process.stderr.read() if self.process.stderr else ""
|
|
159
|
+
self.exception = f"Firmware wipe failed: {stderr}"
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
# Still running - update estimated progress
|
|
163
|
+
elapsed = time.monotonic() - self.start_mono
|
|
164
|
+
progress_pct = min(1.0, elapsed / self.estimated_duration)
|
|
165
|
+
self.total_written = int(self.total_size * progress_pct)
|
|
166
|
+
|
|
167
|
+
time.sleep(check_interval)
|
|
168
|
+
|
|
169
|
+
# Handle abort
|
|
170
|
+
if self.do_abort and self.process:
|
|
171
|
+
self.process.terminate()
|
|
172
|
+
time.sleep(0.5)
|
|
173
|
+
if self.process.poll() is None:
|
|
174
|
+
self.process.kill()
|
|
175
|
+
|
|
176
|
+
except Exception:
|
|
177
|
+
self.exception = traceback.format_exc()
|
|
178
|
+
finally:
|
|
179
|
+
self.done = True
|
|
180
|
+
|
|
181
|
+
def get_status(self):
|
|
182
|
+
"""Get current progress status (thread-safe)
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
tuple: (elapsed_str, pct_str, rate_str, eta_str)
|
|
186
|
+
"""
|
|
187
|
+
mono = time.monotonic()
|
|
188
|
+
elapsed_time = mono - self.start_mono
|
|
189
|
+
|
|
190
|
+
# Calculate percentage based on estimated progress
|
|
191
|
+
pct = (self.total_written / self.total_size) * 100 if self.total_size > 0 else 0
|
|
192
|
+
pct = min(pct, 100)
|
|
193
|
+
pct_str = f'{int(round(pct))}%'
|
|
194
|
+
|
|
195
|
+
if self.do_abort:
|
|
196
|
+
pct_str = 'STOP'
|
|
197
|
+
|
|
198
|
+
# Show "FW" to indicate firmware operation
|
|
199
|
+
rate_str = 'FW'
|
|
200
|
+
|
|
201
|
+
# Calculate ETA based on estimated duration
|
|
202
|
+
if pct < 100:
|
|
203
|
+
remaining = self.estimated_duration - elapsed_time
|
|
204
|
+
eta_str = Utils.ago_str(max(0, int(remaining)))
|
|
205
|
+
else:
|
|
206
|
+
eta_str = '0'
|
|
207
|
+
|
|
208
|
+
elapsed_str = Utils.ago_str(int(round(elapsed_time)))
|
|
209
|
+
|
|
210
|
+
return elapsed_str, pct_str, rate_str, eta_str
|
|
211
|
+
|
|
212
|
+
def get_summary_dict(self):
|
|
213
|
+
"""Generate summary dictionary for structured logging
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
dict: Summary with step details
|
|
217
|
+
"""
|
|
218
|
+
mono = time.monotonic()
|
|
219
|
+
elapsed = mono - self.start_mono
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
"step": f"firmware {self.wipe_name} {self.device_path}",
|
|
223
|
+
"elapsed": Utils.ago_str(int(elapsed)),
|
|
224
|
+
"rate": "Firmware",
|
|
225
|
+
"command": ' '.join(self._build_command()),
|
|
226
|
+
"bytes_written": self.total_written,
|
|
227
|
+
"bytes_total": self.total_size,
|
|
228
|
+
"result": "completed" if self.total_written == self.total_size else "partial"
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class NvmeWipeTask(FirmwareWipeTask):
|
|
233
|
+
"""NVMe firmware wipe using nvme-cli
|
|
234
|
+
|
|
235
|
+
Supports various sanitize and format operations:
|
|
236
|
+
- Sanitize: Crypto Erase, Block Erase, Overwrite
|
|
237
|
+
- Format: Crypto Erase, User Data Erase
|
|
238
|
+
|
|
239
|
+
Example command_args:
|
|
240
|
+
- 'sanitize --action=0x04' (Crypto Erase)
|
|
241
|
+
- 'format --ses=2' (Format with Crypto Erase)
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
def _estimate_duration(self):
|
|
245
|
+
"""Estimate NVMe wipe duration
|
|
246
|
+
|
|
247
|
+
Most NVMe sanitize/format operations complete in seconds.
|
|
248
|
+
Crypto erase: 2-10 seconds
|
|
249
|
+
Block erase: 10-30 seconds
|
|
250
|
+
Overwrite: 30-120 seconds
|
|
251
|
+
"""
|
|
252
|
+
if 'sanitize' in self.command_args:
|
|
253
|
+
if 'crypto' in self.command_args or '0x04' in self.command_args:
|
|
254
|
+
return 10 # Crypto erase is very fast
|
|
255
|
+
elif 'block' in self.command_args or '0x02' in self.command_args:
|
|
256
|
+
return 30
|
|
257
|
+
else: # Overwrite
|
|
258
|
+
return 120
|
|
259
|
+
else: # Format
|
|
260
|
+
return 30
|
|
261
|
+
|
|
262
|
+
def _build_command(self):
|
|
263
|
+
"""Build nvme command
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
list: ['nvme', 'sanitize', '--action=0x04', '/dev/nvme0n1']
|
|
267
|
+
"""
|
|
268
|
+
# Parse command_args: 'sanitize --action=0x04'
|
|
269
|
+
parts = self.command_args.split()
|
|
270
|
+
cmd = ['nvme'] + parts + [self.device_path]
|
|
271
|
+
return cmd
|
|
272
|
+
|
|
273
|
+
def _check_completion(self):
|
|
274
|
+
"""Check NVMe wipe completion
|
|
275
|
+
|
|
276
|
+
Can optionally poll 'nvme sanitize-log' for actual progress.
|
|
277
|
+
For now, just check if process exited.
|
|
278
|
+
"""
|
|
279
|
+
if self.process and self.process.poll() is not None:
|
|
280
|
+
return self.process.returncode == 0
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
# TODO: Implement real-time progress polling via 'nvme sanitize-log'
|
|
284
|
+
# def _get_sanitize_progress(self):
|
|
285
|
+
# """Query actual sanitize progress from device"""
|
|
286
|
+
# try:
|
|
287
|
+
# result = subprocess.run(
|
|
288
|
+
# ['nvme', 'sanitize-log', self.device_path, '-o', 'json'],
|
|
289
|
+
# capture_output=True, text=True, timeout=5
|
|
290
|
+
# )
|
|
291
|
+
# if result.returncode == 0:
|
|
292
|
+
# data = json.loads(result.stdout)
|
|
293
|
+
# # Parse progress from data
|
|
294
|
+
# return progress_pct
|
|
295
|
+
# except:
|
|
296
|
+
# pass
|
|
297
|
+
# return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class SataWipeTask(FirmwareWipeTask):
|
|
301
|
+
"""SATA/ATA firmware wipe using hdparm
|
|
302
|
+
|
|
303
|
+
Uses ATA Security Erase command:
|
|
304
|
+
- Normal Erase: Writes zeros to all sectors (slow)
|
|
305
|
+
- Enhanced Erase: Cryptographic erase or vendor-specific (fast)
|
|
306
|
+
|
|
307
|
+
Example command_args:
|
|
308
|
+
- '--user-master u --security-erase NULL'
|
|
309
|
+
- '--user-master u --security-erase-enhanced NULL'
|
|
310
|
+
|
|
311
|
+
Note: Requires setting a temporary password before erase.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def _estimate_duration(self):
|
|
315
|
+
"""Estimate SATA wipe duration
|
|
316
|
+
|
|
317
|
+
Enhanced erase: 2-10 minutes (varies by vendor)
|
|
318
|
+
Normal erase: ~1 hour per TB
|
|
319
|
+
"""
|
|
320
|
+
if 'enhanced' in self.command_args:
|
|
321
|
+
return 600 # 10 minutes for enhanced
|
|
322
|
+
else:
|
|
323
|
+
# Estimate based on size: 1 hour per TB
|
|
324
|
+
size_tb = self.total_size / (1024**4)
|
|
325
|
+
hours = max(0.5, size_tb)
|
|
326
|
+
return int(hours * 3600)
|
|
327
|
+
|
|
328
|
+
def _build_command(self):
|
|
329
|
+
"""Build hdparm command
|
|
330
|
+
|
|
331
|
+
For security erase, we need to:
|
|
332
|
+
1. Set password: hdparm --user-master u --security-set-pass NULL /dev/sda
|
|
333
|
+
2. Erase: hdparm --user-master u --security-erase NULL /dev/sda
|
|
334
|
+
|
|
335
|
+
We'll just build the erase command - password setting happens in run_task
|
|
336
|
+
"""
|
|
337
|
+
# Parse: '--user-master u --security-erase-enhanced NULL'
|
|
338
|
+
parts = self.command_args.split()
|
|
339
|
+
cmd = ['hdparm'] + parts + [self.device_path]
|
|
340
|
+
return cmd
|
|
341
|
+
|
|
342
|
+
def _set_ata_password(self):
|
|
343
|
+
"""Set temporary ATA password before erase
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
bool: True if successful
|
|
347
|
+
"""
|
|
348
|
+
try:
|
|
349
|
+
cmd = ['hdparm', '--user-master', 'u', '--security-set-pass', 'NULL', self.device_path]
|
|
350
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
351
|
+
return result.returncode == 0
|
|
352
|
+
except Exception as e:
|
|
353
|
+
self.exception = f"Failed to set ATA password: {e}"
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
def run_task(self):
|
|
357
|
+
"""Execute SATA firmware wipe (overrides base to add password step)"""
|
|
358
|
+
try:
|
|
359
|
+
# Step 1: Set temporary password
|
|
360
|
+
if not self._set_ata_password():
|
|
361
|
+
self.exception = "Failed to set ATA security password"
|
|
362
|
+
self.done = True
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
# Step 2: Execute erase command (base class handles this)
|
|
366
|
+
super().run_task()
|
|
367
|
+
|
|
368
|
+
except Exception:
|
|
369
|
+
self.exception = traceback.format_exc()
|
|
370
|
+
self.done = True
|
dwipe/LsblkMonitor.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LsblkMonitor - Background thread for monitoring block device changes
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LsblkMonitor:
|
|
10
|
+
"""Background monitor that checks for block device changes and runs lsblk"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, check_interval=0.2):
|
|
13
|
+
"""
|
|
14
|
+
Initialize the lsblk monitor.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
check_interval: How often to check for changes (seconds)
|
|
18
|
+
"""
|
|
19
|
+
self.check_interval = check_interval
|
|
20
|
+
self.lsblk_str = ""
|
|
21
|
+
self._lock = threading.Lock()
|
|
22
|
+
self._thread = None
|
|
23
|
+
self._stop_event = threading.Event()
|
|
24
|
+
self.last_fingerprint = None
|
|
25
|
+
|
|
26
|
+
def start(self):
|
|
27
|
+
"""Start the background monitoring thread"""
|
|
28
|
+
if self._thread is not None and self._thread.is_alive():
|
|
29
|
+
return # Already running
|
|
30
|
+
|
|
31
|
+
self._stop_event.clear()
|
|
32
|
+
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
|
33
|
+
self._thread.start()
|
|
34
|
+
|
|
35
|
+
def stop(self):
|
|
36
|
+
"""Stop the background monitoring thread"""
|
|
37
|
+
self._stop_event.set()
|
|
38
|
+
if self._thread is not None:
|
|
39
|
+
self._thread.join(timeout=1.0)
|
|
40
|
+
|
|
41
|
+
def check_for_changes(self):
|
|
42
|
+
"""
|
|
43
|
+
Check if block devices or partitions have changed.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True if changes detected, False otherwise
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
# 1. Quickest check: Does the list of block devices match?
|
|
50
|
+
# This catches "Forget" (DEL) and "Scan" (!) events immediately.
|
|
51
|
+
current_devs = os.listdir('/sys/class/block')
|
|
52
|
+
|
|
53
|
+
# 2. Secondary check: Do the partition sizes/counts match?
|
|
54
|
+
with open('/proc/partitions', 'r', encoding='utf-8') as f:
|
|
55
|
+
current_parts = f.read()
|
|
56
|
+
|
|
57
|
+
# Create a combined "Fingerprint"
|
|
58
|
+
fingerprint = f"{len(current_devs)}|{current_parts}"
|
|
59
|
+
|
|
60
|
+
if fingerprint != self.last_fingerprint:
|
|
61
|
+
self.last_fingerprint = fingerprint
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
65
|
+
# If we can't read /sys or /proc, default to True
|
|
66
|
+
# so we don't get stuck with a blank screen.
|
|
67
|
+
return True
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
def _run_lsblk(self):
|
|
71
|
+
"""
|
|
72
|
+
Run lsblk and capture output in JSON format matching DeviceInfo.parse_lsblk requirements.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
JSON output string from lsblk command
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
result = subprocess.run(
|
|
79
|
+
['lsblk', '-J', '--bytes', '-o',
|
|
80
|
+
'NAME,MAJ:MIN,FSTYPE,TYPE,LABEL,PARTLABEL,FSUSE%,SIZE,MOUNTPOINTS,UUID,PARTUUID,SERIAL'],
|
|
81
|
+
capture_output=True,
|
|
82
|
+
text=True,
|
|
83
|
+
timeout=5.0,
|
|
84
|
+
check=False
|
|
85
|
+
)
|
|
86
|
+
return result.stdout
|
|
87
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
88
|
+
return "" # Return empty string on error
|
|
89
|
+
|
|
90
|
+
def _monitor_loop(self):
|
|
91
|
+
"""Background thread loop that monitors for changes"""
|
|
92
|
+
while not self._stop_event.is_set():
|
|
93
|
+
if self.check_for_changes():
|
|
94
|
+
# Changes detected, run lsblk
|
|
95
|
+
lsblk_output = self._run_lsblk()
|
|
96
|
+
|
|
97
|
+
# Store the result in a thread-safe manner
|
|
98
|
+
with self._lock:
|
|
99
|
+
self.lsblk_str = lsblk_output
|
|
100
|
+
|
|
101
|
+
# Sleep for the check interval
|
|
102
|
+
self._stop_event.wait(self.check_interval)
|
|
103
|
+
|
|
104
|
+
def get_and_clear(self):
|
|
105
|
+
"""
|
|
106
|
+
Get the latest lsblk output and clear it.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
String containing lsblk output, or empty string if no new data
|
|
110
|
+
"""
|
|
111
|
+
with self._lock:
|
|
112
|
+
result = self.lsblk_str
|
|
113
|
+
self.lsblk_str = ""
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
def peek(self):
|
|
117
|
+
"""
|
|
118
|
+
Get the latest lsblk output without clearing it.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
String containing lsblk output, or empty string if no new data
|
|
122
|
+
"""
|
|
123
|
+
with self._lock:
|
|
124
|
+
return self.lsblk_str
|
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
|
|
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,7 +24,7 @@ class PersistentState:
|
|
|
24
24
|
self.config_path = Path(config_path)
|
|
25
25
|
self.state = {
|
|
26
26
|
'theme': 'default',
|
|
27
|
-
'wipe_mode': '
|
|
27
|
+
'wipe_mode': '+V', # '+V' (verify) or '-V' (no verify)
|
|
28
28
|
'passes': 1, # 1, 2, or 4 wipe pass
|
|
29
29
|
'confirmation': 'YES', # 'Y', 'y', 'YES', 'yes', 'device'
|
|
30
30
|
'verify_pct': 2, # 0, 2, 5, 10, 25, 50, 100
|
|
@@ -32,7 +32,7 @@ class PersistentState:
|
|
|
32
32
|
'slowdown_stop': 16,
|
|
33
33
|
'stall_timeout': 60,
|
|
34
34
|
'port_serial': False,
|
|
35
|
-
'devices': {} # device_id -> {
|
|
35
|
+
'devices': {} # device_id -> {blocked, last_seen, last_name, size_bytes}
|
|
36
36
|
}
|
|
37
37
|
self.dirty = False
|
|
38
38
|
self.max_devices = 400
|
|
@@ -63,6 +63,18 @@ class PersistentState:
|
|
|
63
63
|
with open(self.config_path, 'r', encoding='utf-8') as f:
|
|
64
64
|
loaded = json.load(f)
|
|
65
65
|
self.state.update(loaded)
|
|
66
|
+
|
|
67
|
+
# Migrate old wipe_mode values to new format
|
|
68
|
+
old_wipe_mode = self.state.get('wipe_mode', '+V')
|
|
69
|
+
if old_wipe_mode not in ['+V', '-V']:
|
|
70
|
+
# Old format: 'Zero', 'Zero+V', 'Rand', 'Rand+V'
|
|
71
|
+
# Convert to new format: '+V' or '-V'
|
|
72
|
+
if '+V' in str(old_wipe_mode):
|
|
73
|
+
self.state['wipe_mode'] = '+V'
|
|
74
|
+
else:
|
|
75
|
+
self.state['wipe_mode'] = '-V'
|
|
76
|
+
self.dirty = True # Save the migration
|
|
77
|
+
|
|
66
78
|
except (json.JSONDecodeError, IOError) as e:
|
|
67
79
|
print(f'Warning: Could not load state from {self.config_path}: {e}')
|
|
68
80
|
|
|
@@ -134,24 +146,28 @@ class PersistentState:
|
|
|
134
146
|
return f'fallback:{fallback_id}'
|
|
135
147
|
|
|
136
148
|
def get_device_locked(self, partition):
|
|
137
|
-
"""Check if a device is locked
|
|
149
|
+
"""Check if a device is blocked (backward compatible with 'locked')
|
|
138
150
|
|
|
139
151
|
Args:
|
|
140
152
|
partition: SimpleNamespace with device info
|
|
141
153
|
|
|
142
154
|
Returns:
|
|
143
|
-
bool: True if device is
|
|
155
|
+
bool: True if device is blocked
|
|
144
156
|
"""
|
|
145
157
|
device_id = self.make_device_id(partition)
|
|
146
158
|
device_state = self.state['devices'].get(device_id, {})
|
|
159
|
+
|
|
160
|
+
# Check new 'blocked' field first, fall back to old 'locked' field for backward compatibility
|
|
161
|
+
if 'blocked' in device_state:
|
|
162
|
+
return device_state['blocked']
|
|
147
163
|
return device_state.get('locked', False)
|
|
148
164
|
|
|
149
165
|
def set_device_locked(self, partition, locked):
|
|
150
|
-
"""Set device
|
|
166
|
+
"""Set device block state
|
|
151
167
|
|
|
152
168
|
Args:
|
|
153
169
|
partition: SimpleNamespace with device info
|
|
154
|
-
locked: bool, True to
|
|
170
|
+
locked: bool, True to block device (parameter name kept for API compatibility)
|
|
155
171
|
"""
|
|
156
172
|
device_id = self.make_device_id(partition)
|
|
157
173
|
now = int(time.time())
|
|
@@ -160,7 +176,9 @@ class PersistentState:
|
|
|
160
176
|
self.state['devices'][device_id] = {}
|
|
161
177
|
|
|
162
178
|
device_state = self.state['devices'][device_id]
|
|
163
|
-
device_state['
|
|
179
|
+
device_state['blocked'] = locked # Only save 'blocked', not 'locked'
|
|
180
|
+
# Remove old 'locked' field if it exists (gradual migration)
|
|
181
|
+
device_state.pop('locked', None)
|
|
164
182
|
device_state['last_seen'] = now
|
|
165
183
|
device_state['last_name'] = partition.name
|
|
166
184
|
device_state['size_bytes'] = partition.size_bytes
|