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/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 (oldest first)
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
- # Work backwards (newest first) until we hit target
306
- for effective_age, line, line_size in reversed(weighted):
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)