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/SataTool.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
""" TBD """
|
|
3
|
+
# pylint: disable=invalid-name,multiple-statements,line-too-long
|
|
4
|
+
# pylint: disable=broad-exception-caught,too-many-nested-blocks
|
|
5
|
+
# pylint: disable=too-many-locals,too-many-instance-attributes
|
|
6
|
+
# pylint: disable=too-many-boolean-expressions
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from types import SimpleNamespace
|
|
14
|
+
from .Utils import Utils
|
|
15
|
+
from .Tunables import Tunables
|
|
16
|
+
|
|
17
|
+
class WipeIpDb:
|
|
18
|
+
"""Manages the persistent state of in-progress firmware wipes."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, directory):
|
|
21
|
+
self.path = os.path.join(directory, "firmware-wipes-ip.json")
|
|
22
|
+
self._ensure_file()
|
|
23
|
+
|
|
24
|
+
def _ensure_file(self):
|
|
25
|
+
"""Creates an empty JSON set if the file doesn't exist."""
|
|
26
|
+
if not os.path.exists(self.path):
|
|
27
|
+
self._write_dict({})
|
|
28
|
+
Utils.fix_file_ownership(self.path)
|
|
29
|
+
|
|
30
|
+
def _read_dict(self):
|
|
31
|
+
try:
|
|
32
|
+
now = time.time()
|
|
33
|
+
with open(self.path, 'r', encoding='utf-8') as f:
|
|
34
|
+
data, cleaned = json.load(f), {}
|
|
35
|
+
for key, unixtime in data.items():
|
|
36
|
+
if now - unixtime <= 7 * 24 * 3600:
|
|
37
|
+
cleaned[key] = unixtime
|
|
38
|
+
if len(cleaned) != len(data):
|
|
39
|
+
self._write_dict(cleaned)
|
|
40
|
+
return cleaned
|
|
41
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
def _write_dict(self, key_dict):
|
|
45
|
+
with open(self.path, 'w', encoding='utf-8') as f:
|
|
46
|
+
json.dump(key_dict, f, indent=4)
|
|
47
|
+
|
|
48
|
+
def start_wipe(self, key):
|
|
49
|
+
"""Adds a key to the journal."""
|
|
50
|
+
key_dict = self._read_dict()
|
|
51
|
+
key_dict[key] = time.time()
|
|
52
|
+
self._write_dict(key_dict)
|
|
53
|
+
|
|
54
|
+
def end_wipe(self, key):
|
|
55
|
+
"""Removes a key from the journal (successful completion)."""
|
|
56
|
+
self.purge(key)
|
|
57
|
+
|
|
58
|
+
def purge(self, key):
|
|
59
|
+
"""Removes key if exists and returns True/False for recovery logic."""
|
|
60
|
+
key_dict = self._read_dict()
|
|
61
|
+
if key in key_dict:
|
|
62
|
+
del key_dict[key]
|
|
63
|
+
self._write_dict(key_dict)
|
|
64
|
+
return True
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
class SataTool:
|
|
68
|
+
""" TBD """
|
|
69
|
+
tunables = Tunables()
|
|
70
|
+
|
|
71
|
+
def __init__(self, device_name, timeout=10):
|
|
72
|
+
self.timeout = timeout
|
|
73
|
+
if device_name.startswith('/dev/'):
|
|
74
|
+
self.device_name = device_name[len('/dev/'):]
|
|
75
|
+
self.device_path = device_name
|
|
76
|
+
else:
|
|
77
|
+
self.device_name = device_name
|
|
78
|
+
self.device_path = f'/dev/{self.device_name}'
|
|
79
|
+
self.secures = None # last parse result of security section values
|
|
80
|
+
self.job = SimpleNamespace() # data about running wipe job
|
|
81
|
+
self.job.process = None # set when wipe job is running
|
|
82
|
+
self.job.wipe_started_mono = None # set when wipe job started
|
|
83
|
+
self.job.est_secs = 0
|
|
84
|
+
self.original_data = None
|
|
85
|
+
self.model_serial_key = '<=>'.join(self.get_disk_identifiers())
|
|
86
|
+
self.wipe_ip_db = WipeIpDb(directory=Utils.get_config_dir())
|
|
87
|
+
|
|
88
|
+
def get_disk_identifiers(self):
|
|
89
|
+
"""
|
|
90
|
+
Non-blocking retrieval of Model and Serial.
|
|
91
|
+
Works even if the drive is in a 'D' state (SATA Wipe).
|
|
92
|
+
"""
|
|
93
|
+
model, serial = 'unknown', 'unknown'
|
|
94
|
+
dev_name = self.device_name
|
|
95
|
+
|
|
96
|
+
# 1. Get Model from sysfs (Fast and Safe)
|
|
97
|
+
model_path = f"/sys/class/block/{dev_name}/device/model"
|
|
98
|
+
try:
|
|
99
|
+
with open(model_path, 'r', encoding='utf-8') as f:
|
|
100
|
+
model = f.read().strip()
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
# 2. Get Serial from /dev/disk/by-id (Kernel Cache)
|
|
105
|
+
disk_id_path = "/dev/disk/by-id"
|
|
106
|
+
if os.path.exists(disk_id_path):
|
|
107
|
+
for link in os.listdir(disk_id_path):
|
|
108
|
+
# We want the primary link, not the partition links (which have -partX)
|
|
109
|
+
if "-part" in link:
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
# Check if this link points to our device (e.g., ../../sdb)
|
|
113
|
+
try:
|
|
114
|
+
target = os.readlink(os.path.join(disk_id_path, link))
|
|
115
|
+
if os.path.basename(target) == dev_name:
|
|
116
|
+
# Logic for ATA/SATA
|
|
117
|
+
if link.startswith('ata-'):
|
|
118
|
+
# Format: ata-MODEL_SERIAL
|
|
119
|
+
# Example: ata-ST1000LM035-1RK172_WDE12345
|
|
120
|
+
parts = link[4:].split('_')
|
|
121
|
+
if len(parts) > 1:
|
|
122
|
+
serial = parts[-1]
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
# Logic for NVMe
|
|
126
|
+
if link.startswith('nvme-'):
|
|
127
|
+
# Format: nvme-MODEL_SERIAL
|
|
128
|
+
parts = link[5:].split('_')
|
|
129
|
+
if len(parts) > 1:
|
|
130
|
+
serial = parts[-1]
|
|
131
|
+
break
|
|
132
|
+
except Exception:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
return model, serial
|
|
136
|
+
|
|
137
|
+
def run_cmd(self, argv, timeout=None):
|
|
138
|
+
""" RUN a command return the output """
|
|
139
|
+
timeout = self.timeout if timeout is None else timeout
|
|
140
|
+
cmd = ['hdparm']
|
|
141
|
+
cmd += argv if isinstance(argv, (list, tuple)) else [argv]
|
|
142
|
+
cmd += [self.device_path]
|
|
143
|
+
output = subprocess.run(cmd, check=False, capture_output=True,
|
|
144
|
+
text=True, timeout=timeout)
|
|
145
|
+
return output
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _make_ns():
|
|
149
|
+
return SimpleNamespace(supported=False, enabled=False,
|
|
150
|
+
frozen=False, locked=False, expired=False,
|
|
151
|
+
enhanced_erase_supported=False, erase_est_secs=[4*60*60],
|
|
152
|
+
sanitize_supported=False, sanitize_crypto_supported=False,
|
|
153
|
+
sanitize_block_supported=False, sanitize_overwrite_supported=False,
|
|
154
|
+
has_security_feature=False) # True if hdparm found a Security block
|
|
155
|
+
|
|
156
|
+
def _parse_output_to_secures(self, output):
|
|
157
|
+
secures = self._make_ns()
|
|
158
|
+
self.secures = secures
|
|
159
|
+
# Look specifically for the security block
|
|
160
|
+
security_match = re.search(r"(?i)Security:.*?(?=\n\w|\Z)", output, re.DOTALL)
|
|
161
|
+
if not security_match:
|
|
162
|
+
return secures # has_security_feature stays False
|
|
163
|
+
|
|
164
|
+
secures.has_security_feature = True
|
|
165
|
+
sec_block = security_match.group(0)
|
|
166
|
+
|
|
167
|
+
# Better logic: Check if the keyword exists AND is not preceded by 'not'
|
|
168
|
+
secures.supported = "supported" in sec_block and "not supported" not in sec_block
|
|
169
|
+
secures.enabled = bool(re.search(r"(?<!not)\s+enabled", sec_block))
|
|
170
|
+
secures.locked = bool(re.search(r"(?<!not)\s+locked", sec_block))
|
|
171
|
+
secures.frozen = bool(re.search(r"(?<!not)\s+frozen", sec_block))
|
|
172
|
+
secures.expired = bool(re.search(r"(?<!not)\s+expired", sec_block))
|
|
173
|
+
secures.enhanced_erase_supported = "supported: enhanced erase" in sec_block
|
|
174
|
+
|
|
175
|
+
minutes = re.findall(r"(\d+)min", sec_block)
|
|
176
|
+
if minutes:
|
|
177
|
+
secures.erase_est_secs = [60*int(m) for m in minutes]
|
|
178
|
+
|
|
179
|
+
# Look for SANITIZE capabilities in Commands/features section
|
|
180
|
+
# hdparm -I shows these as separate lines like:
|
|
181
|
+
# * SANITIZE feature set
|
|
182
|
+
# * CRYPTO_SCRAMBLE_EXT command
|
|
183
|
+
# * BLOCK_ERASE_EXT command
|
|
184
|
+
# * OVERWRITE_EXT command
|
|
185
|
+
if re.search(r'\*\s+SANITIZE feature set', output):
|
|
186
|
+
secures.sanitize_supported = True
|
|
187
|
+
secures.sanitize_crypto_supported = bool(re.search(r'\*\s+CRYPTO_SCRAMBLE_EXT', output))
|
|
188
|
+
secures.sanitize_block_supported = bool(re.search(r'\*\s+BLOCK_ERASE_EXT', output))
|
|
189
|
+
secures.sanitize_overwrite_supported = bool(re.search(r'\*\s+OVERWRITE_EXT', output))
|
|
190
|
+
|
|
191
|
+
return secures
|
|
192
|
+
|
|
193
|
+
def refresh_secures(self):
|
|
194
|
+
""" Run a command to get the security values from hdparm """
|
|
195
|
+
was_secures = self.secures
|
|
196
|
+
output = self.run_cmd('-I')
|
|
197
|
+
secures = self._parse_output_to_secures(output.stdout)
|
|
198
|
+
if not was_secures and secures:
|
|
199
|
+
self.rescue_locked_drive() # just in case
|
|
200
|
+
secures = self.secures # may have been updated
|
|
201
|
+
return secures
|
|
202
|
+
|
|
203
|
+
# Logic for your App's Verdict
|
|
204
|
+
def get_wipe_verdict(self): # , override_usb=False):
|
|
205
|
+
""" TBD """
|
|
206
|
+
|
|
207
|
+
# if data["bridge"]["vendor_id"] and not data["bridge"]["whitelisted"] and not override_usb:
|
|
208
|
+
# return "FwErr: USB-Disallowed"
|
|
209
|
+
if not self.secures:
|
|
210
|
+
self.refresh_secures()
|
|
211
|
+
|
|
212
|
+
if not self.secures.has_security_feature:
|
|
213
|
+
return "DumbDevice" # No ATA security feature (e.g., USB thumb drive)
|
|
214
|
+
if not self.secures.supported:
|
|
215
|
+
return "Unsupported"
|
|
216
|
+
if self.secures.frozen:
|
|
217
|
+
return "Frozen"
|
|
218
|
+
if self.secures.expired:
|
|
219
|
+
return "SecurityExpired"
|
|
220
|
+
if self.secures.locked:
|
|
221
|
+
return "Locked" # Already locked with unknown pass
|
|
222
|
+
return "OK"
|
|
223
|
+
|
|
224
|
+
def start_async_wipe(self, use_enhanced=False):
|
|
225
|
+
"""Starts the wipe and returns the process object immediately.
|
|
226
|
+
# --- Monitoring Loop in your Main App ---
|
|
227
|
+
# wipe_proc = tool.start_async_wipe()
|
|
228
|
+
# while wipe_proc.poll() is None:
|
|
229
|
+
# print("Wipe in progress... (Drive is busy)")
|
|
230
|
+
# time.sleep(30)
|
|
231
|
+
#
|
|
232
|
+
# out, err = wipe_proc.communicate()
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
erase_flag = '--security-erase-enhanced' if use_enhanced else '--security-erase'
|
|
236
|
+
cmd = ['hdparm', '--user-master', 'u', erase_flag, 'NULL', self.device_path]
|
|
237
|
+
|
|
238
|
+
# Popen does NOT block. It returns immediately.
|
|
239
|
+
self.job.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
240
|
+
self.job.wipe_started_mono = time.monotonic()
|
|
241
|
+
idx = 0 if use_enhanced else -1
|
|
242
|
+
self.job.est_secs = self.secures.erase_est_secs[idx]
|
|
243
|
+
return self.job.process
|
|
244
|
+
|
|
245
|
+
def start_sanitize_wipe(self, method='crypto'):
|
|
246
|
+
"""Execute SATA Sanitize command (Crypto, Block, or Overwrite).
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
method: 'crypto', 'block', or 'overwrite'
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Popen object for the sanitize command
|
|
253
|
+
"""
|
|
254
|
+
# Map method names to hdparm sanitize flags
|
|
255
|
+
sanitize_map = {
|
|
256
|
+
'sanitize_crypto': '--sanitize-crypto-scramble',
|
|
257
|
+
'sanitize_block': '--sanitize-block-erase',
|
|
258
|
+
'sanitize_overwrite': '--sanitize-overwrite'
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
# Convert method to full method name if needed
|
|
262
|
+
if not method.startswith('sanitize_'):
|
|
263
|
+
method = f'sanitize_{method}'
|
|
264
|
+
|
|
265
|
+
sanitize_flag = sanitize_map.get(method)
|
|
266
|
+
if not sanitize_flag:
|
|
267
|
+
raise ValueError(f"Unknown sanitize method: {method}")
|
|
268
|
+
|
|
269
|
+
cmd = ['hdparm', '--yes-i-know-what-i-am-doing', sanitize_flag]
|
|
270
|
+
|
|
271
|
+
# Overwrite requires a pattern (use zeros)
|
|
272
|
+
if method == 'sanitize_overwrite':
|
|
273
|
+
cmd.append('hex:00000000')
|
|
274
|
+
|
|
275
|
+
cmd.append(self.device_path)
|
|
276
|
+
|
|
277
|
+
# Store command for logging (similar to NvmeTool)
|
|
278
|
+
self.last_command = cmd
|
|
279
|
+
|
|
280
|
+
# Execute asynchronously
|
|
281
|
+
self.job.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
282
|
+
self.job.wipe_started_mono = time.monotonic()
|
|
283
|
+
self.job.est_secs = 120 # Estimate for sanitize operations
|
|
284
|
+
return self.job.process
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def start_wipe(self, use_enhanced=False, password="NULL", db=False):
|
|
288
|
+
"""
|
|
289
|
+
Executes the two-stage ATA Security Erase process.
|
|
290
|
+
Returns structured dict with commands_executed list and final process.
|
|
291
|
+
|
|
292
|
+
Example return:
|
|
293
|
+
{
|
|
294
|
+
'commands_executed': [
|
|
295
|
+
{'cmd': ['hdparm', ...], 'returncode': 0, 'sleep_after': 0},
|
|
296
|
+
{'cmd': ['hdparm', ...], 'returncode': 0, 'sleep_after': 5}
|
|
297
|
+
],
|
|
298
|
+
'process': <Popen object>
|
|
299
|
+
}
|
|
300
|
+
"""
|
|
301
|
+
commands_executed = []
|
|
302
|
+
|
|
303
|
+
# 1. Pre-flight check: Re-verify not frozen
|
|
304
|
+
self.refresh_secures()
|
|
305
|
+
verdict = self.get_wipe_verdict()
|
|
306
|
+
if verdict != "OK":
|
|
307
|
+
return False, f'Drive not in OK state [{verdict}]'
|
|
308
|
+
|
|
309
|
+
# 2. Set the Security Password
|
|
310
|
+
# This moves the drive from 'not enabled' to 'enabled'
|
|
311
|
+
set_pass_argv = ['hdparm'] + ['--user-master', 'u', '--security-set-pass', password]
|
|
312
|
+
if db: print(f"Setting security password for {self.device_name}...")
|
|
313
|
+
res = self.run_cmd(['--user-master', 'u', '--security-set-pass', password])
|
|
314
|
+
|
|
315
|
+
# Record command execution
|
|
316
|
+
commands_executed.append({
|
|
317
|
+
'cmd': ' '.join(set_pass_argv),
|
|
318
|
+
'returncode': res.returncode,
|
|
319
|
+
'sleep_after': self.tunables.post_password_delay
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
time.sleep(self.tunables.post_password_delay)
|
|
323
|
+
|
|
324
|
+
if res.returncode != 0:
|
|
325
|
+
return False, f"Failed to set password: {res.stderr.strip()}"
|
|
326
|
+
|
|
327
|
+
# 3. Verify the drive is now 'enabled'
|
|
328
|
+
self.refresh_secures()
|
|
329
|
+
if not self.secures.enabled:
|
|
330
|
+
return False, "Password command accepted but drive is not 'enabled'."
|
|
331
|
+
time.sleep(self.tunables.post_unlock_delay)
|
|
332
|
+
|
|
333
|
+
# 4. Build erase command (to be executed asynchronously)
|
|
334
|
+
erase_flag = '--security-erase-enhanced' if use_enhanced else '--security-erase'
|
|
335
|
+
erase_cmd = ['hdparm', '--user-master', 'u', erase_flag, 'NULL', self.device_path]
|
|
336
|
+
|
|
337
|
+
# Start async wipe process
|
|
338
|
+
if db:
|
|
339
|
+
process = self.start_async_wipe(use_enhanced=use_enhanced)
|
|
340
|
+
else:
|
|
341
|
+
# When not in db mode, return structured info for logging
|
|
342
|
+
process = None # Will be started by caller
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
'commands_executed': commands_executed,
|
|
346
|
+
'erase_command': erase_cmd, # The final command to be executed
|
|
347
|
+
'process': process
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def rescue_locked_drive(self, password="NULL", db=False):
|
|
353
|
+
"""Attempts to unlock and disable security on a bricked drive."""
|
|
354
|
+
|
|
355
|
+
secures = self.secures
|
|
356
|
+
if secures.supported and not secures.frozen and secures.expired and (
|
|
357
|
+
secures.locked or secures.enabled
|
|
358
|
+
) and self.wipe_ip_db.purge(self.model_serial_key):
|
|
359
|
+
|
|
360
|
+
if db: print(f"Attempting rescue on locked device {self.device_name}...")
|
|
361
|
+
if secures.locked:
|
|
362
|
+
# 1. Unlock
|
|
363
|
+
self.run_cmd(['--user-master', 'u', '--security-unlock', password])
|
|
364
|
+
if secures.enabled or secures.locked:
|
|
365
|
+
# 2. Disable (removes the 'enabled' requirement)
|
|
366
|
+
self.run_cmd(['--user-master', 'u', '--security-disable', password])
|
|
367
|
+
|
|
368
|
+
# 3. Verify
|
|
369
|
+
secures = self.refresh_secures()
|
|
370
|
+
if not secures.locked and not secures.enabled:
|
|
371
|
+
return True, "Drive successfully rescued and unlocked."
|
|
372
|
+
return False, "Rescue failed. Password may be incorrect or drive is 'Expired'."
|
|
373
|
+
return True, "Drive not rescued for any reason"
|
|
374
|
+
|
|
375
|
+
def verify_wipe_result(self):
|
|
376
|
+
"""
|
|
377
|
+
Runs AFTER start_wipe finishes to see if the drive
|
|
378
|
+
actually cleared its security state.
|
|
379
|
+
"""
|
|
380
|
+
# Refresh the namespace
|
|
381
|
+
self.refresh_secures()
|
|
382
|
+
if not self.secures.supported:
|
|
383
|
+
return False, "NotSupported"
|
|
384
|
+
|
|
385
|
+
# If the wipe worked, the drive automatically disables
|
|
386
|
+
# the security password and clears the 'enabled' bit.
|
|
387
|
+
if not self.secures.enabled and not self.secures.locked:
|
|
388
|
+
return True, "ReadyUnlocked"
|
|
389
|
+
|
|
390
|
+
if self.secures.locked:
|
|
391
|
+
return False, "StillLocked"
|
|
392
|
+
|
|
393
|
+
return False, "SecurityEnabled"
|
|
394
|
+
|
|
395
|
+
def test_and_restore_block(self, offset=0):
|
|
396
|
+
"""
|
|
397
|
+
Reads, Writes a test pattern, Reads, and Restores the original data.
|
|
398
|
+
Returns (success_bool, message)
|
|
399
|
+
"""
|
|
400
|
+
test_pattern = b"WIPE_VERIFY_PATTERN_" + os.urandom(8)
|
|
401
|
+
test_pattern = test_pattern.ljust(512, b'\x00')
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
# Step 1: Read original
|
|
405
|
+
if not self.original_data:
|
|
406
|
+
with open(self.device_path, 'rb') as f:
|
|
407
|
+
f.seek(offset)
|
|
408
|
+
self.original_data = f.read(512)
|
|
409
|
+
|
|
410
|
+
if len(self.original_data) != 512:
|
|
411
|
+
self.original_data = None
|
|
412
|
+
return False, "Could not read full 512 bytes for backup."
|
|
413
|
+
|
|
414
|
+
# Step 2: Write Test Pattern
|
|
415
|
+
# Using O_SYNC to ensure it hits the controller
|
|
416
|
+
fd = os.open(self.device_path, os.O_WRONLY | os.O_SYNC)
|
|
417
|
+
try:
|
|
418
|
+
os.lseek(fd, offset, os.SEEK_SET)
|
|
419
|
+
os.write(fd, test_pattern)
|
|
420
|
+
os.fsync(fd)
|
|
421
|
+
finally:
|
|
422
|
+
os.close(fd)
|
|
423
|
+
|
|
424
|
+
# Step 3: Clear Cache and Verify Write
|
|
425
|
+
subprocess.run(['blockdev', '--flushbufs', self.device_path], check=True)
|
|
426
|
+
|
|
427
|
+
with open(self.device_path, 'rb') as f:
|
|
428
|
+
f.seek(offset)
|
|
429
|
+
check_write = f.read(512)
|
|
430
|
+
|
|
431
|
+
if check_write != test_pattern:
|
|
432
|
+
return False, "Verification failed: Test pattern was not correctly written."
|
|
433
|
+
|
|
434
|
+
# Step 4: Restore original data
|
|
435
|
+
fd = os.open(self.device_path, os.O_WRONLY | os.O_SYNC)
|
|
436
|
+
try:
|
|
437
|
+
os.lseek(fd, offset, os.SEEK_SET)
|
|
438
|
+
os.write(fd, self.original_data)
|
|
439
|
+
os.fsync(fd)
|
|
440
|
+
finally:
|
|
441
|
+
os.close(fd)
|
|
442
|
+
|
|
443
|
+
# Step 5: Final Verification of Restoration
|
|
444
|
+
subprocess.run(['blockdev', '--flushbufs', self.device_path], check=True)
|
|
445
|
+
|
|
446
|
+
with open(self.device_path, 'rb') as f:
|
|
447
|
+
f.seek(offset)
|
|
448
|
+
final_check = f.read(512)
|
|
449
|
+
|
|
450
|
+
if final_check == self.original_data:
|
|
451
|
+
return True, "DevReadyForWrite"
|
|
452
|
+
return False, "CRITICAL: Failed to restore original data correctly!"
|
|
453
|
+
|
|
454
|
+
except Exception as e:
|
|
455
|
+
return False, f"I/O Transaction error: {str(e)}"
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def main():
|
|
459
|
+
""" TBD"""
|
|
460
|
+
# pylint: disable=import-outside-toplevel
|
|
461
|
+
import glob
|
|
462
|
+
import argparse
|
|
463
|
+
parser = argparse.ArgumentParser()
|
|
464
|
+
parser.add_argument('--wipe', action='store_true',
|
|
465
|
+
help='firmware wipe of given disk')
|
|
466
|
+
parser.add_argument('devs', nargs='*', help="list if sdX")
|
|
467
|
+
opts = parser.parse_args()
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
if not opts.wipe and not opts.devs:
|
|
471
|
+
pattern = '/dev/sd?'
|
|
472
|
+
opts.devs = glob.glob(pattern)
|
|
473
|
+
for dev in opts.devs:
|
|
474
|
+
tool = SataTool(os.path.basename(dev))
|
|
475
|
+
secures = tool.refresh_secures()
|
|
476
|
+
verdict = tool.get_wipe_verdict()
|
|
477
|
+
print(dev, f'{verdict=}')
|
|
478
|
+
for attr, val in vars(secures).items():
|
|
479
|
+
print(f' {attr}: {val}')
|
|
480
|
+
if opts.wipe and verdict == 'OK':
|
|
481
|
+
print(f' + start wipe {tool.device_path!r}')
|
|
482
|
+
# --- Monitoring Loop ---
|
|
483
|
+
job = tool.start_wipe(secures.enhanced_erase_supported, db=True)
|
|
484
|
+
while job.process.poll() is None:
|
|
485
|
+
elapsed = round(time.monotonic() - job.wipe_started_mono)
|
|
486
|
+
elapsed_str = f'{elapsed//60}m{elapsed%60}s'
|
|
487
|
+
left = max(job.est_secs - elapsed, 1)
|
|
488
|
+
left_str = f'{left//60}m{left%60}s'
|
|
489
|
+
print(f"Wipe in progress... {elapsed_str} -{left_str}")
|
|
490
|
+
time.sleep(10)
|
|
491
|
+
elapsed = round(time.monotonic() - job.wipe_started_mono)
|
|
492
|
+
elapsed_str = f'{elapsed//60}m{elapsed%60}s'
|
|
493
|
+
print("Wipe complete at {elapsed_str}")
|
|
494
|
+
out, err = job.process.communicate()
|
|
495
|
+
print('\nOUT:\n' + out)
|
|
496
|
+
print('\nERR:\n' + err)
|
|
497
|
+
|
|
498
|
+
if __name__ == "__main__":
|
|
499
|
+
main()
|
dwipe/StructuredLogger.py
CHANGED
|
@@ -294,7 +294,8 @@ class StructuredLogger:
|
|
|
294
294
|
effective_age = age_seconds / self.ERR_AGE_WEIGHT if level == 'ERR' else age_seconds
|
|
295
295
|
weighted.append((effective_age, line, line_size))
|
|
296
296
|
|
|
297
|
-
# Sort by effective age (
|
|
297
|
+
# Sort by effective age (newest first = lowest age)
|
|
298
|
+
|
|
298
299
|
weighted.sort(key=lambda x: x[0])
|
|
299
300
|
|
|
300
301
|
# Target: keep newest entries until total size <= MAX_LOG_SIZE * TRIM_TO_RATIO
|
|
@@ -302,8 +303,8 @@ class StructuredLogger:
|
|
|
302
303
|
kept = []
|
|
303
304
|
total_size = 0
|
|
304
305
|
|
|
305
|
-
#
|
|
306
|
-
for effective_age, line, line_size in
|
|
306
|
+
# Keep newest entries (lowest effective age) until we hit target
|
|
307
|
+
for effective_age, line, line_size in weighted:
|
|
307
308
|
if total_size + line_size <= target_size:
|
|
308
309
|
kept.append(line)
|
|
309
310
|
total_size += line_size
|
dwipe/Tunables.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
""" Ini file for dwipe"""
|
|
3
|
+
import configparser
|
|
4
|
+
import os
|
|
5
|
+
from .Utils import Utils
|
|
6
|
+
|
|
7
|
+
class Tunables:
|
|
8
|
+
"""Manages dwipe configuration and provides hardware-tuning defaults."""
|
|
9
|
+
singleton = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
assert not Tunables.singleton
|
|
14
|
+
|
|
15
|
+
config_dir = Utils.get_config_dir()
|
|
16
|
+
self.path = config_dir / 'tunables.ini'
|
|
17
|
+
self.config = configparser.ConfigParser()
|
|
18
|
+
|
|
19
|
+
# Load existing or create default
|
|
20
|
+
if not os.path.exists(self.path):
|
|
21
|
+
self._create_default()
|
|
22
|
+
else:
|
|
23
|
+
self.config.read(self.path)
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def get_singleton(self):
|
|
27
|
+
""" Get the singleton Tunable object """
|
|
28
|
+
if not Tunables.get_singleton:
|
|
29
|
+
Tunables.singleton = Tunables()
|
|
30
|
+
return Tunables.singleton
|
|
31
|
+
|
|
32
|
+
def _create_default(self):
|
|
33
|
+
"""Generates a default .ini file with recommended 'turkey' hardware timings."""
|
|
34
|
+
self.config['SATA_TIMING'] = {
|
|
35
|
+
'post_password_delay': '1.0',
|
|
36
|
+
'post_unlock_delay': '1.5',
|
|
37
|
+
'reappearance_timeout': '120',
|
|
38
|
+
'poll_interval': '5.0'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
with open(self.path, 'w', encoding='utf-8') as f:
|
|
42
|
+
f.write("# dwipe Configuration - Tune these for 'grumpy' hardware\n")
|
|
43
|
+
self.config.write(f)
|
|
44
|
+
|
|
45
|
+
# Fix ownership when running with sudo
|
|
46
|
+
Utils.fix_file_ownership(self.path)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def post_password_delay(self):
|
|
50
|
+
return self.config.getfloat('SATA_TIMING', 'post_password_delay', fallback=1.0)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def post_unlock_delay(self):
|
|
54
|
+
return self.config.getfloat('SATA_TIMING', 'post_unlock_delay', fallback=1.5)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def reappearance_timeout(self):
|
|
58
|
+
return self.config.getfloat('SATA_TIMING', 'reappearance_timeout', fallback=120.0)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def poll_interval(self):
|
|
62
|
+
return self.config.getfloat('SATA_TIMING', 'poll_interval', fallback=5.0)
|