dwipe 2.0.1__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 +703 -177
- dwipe/DeviceWorker.py +566 -0
- dwipe/DiskWipe.py +953 -214
- dwipe/DrivePreChecker.py +203 -0
- dwipe/FirmwareWipeTask.py +865 -0
- dwipe/NvmeTool.py +225 -0
- dwipe/PersistentState.py +45 -16
- dwipe/Prereqs.py +84 -0
- dwipe/SataTool.py +499 -0
- dwipe/StructuredLogger.py +644 -0
- dwipe/Tunables.py +62 -0
- dwipe/Utils.py +298 -3
- dwipe/VerifyTask.py +412 -0
- dwipe/WipeJob.py +631 -171
- dwipe/WipeTask.py +150 -0
- dwipe/WriteTask.py +402 -0
- dwipe/main.py +34 -9
- dwipe-3.0.0.dist-info/METADATA +566 -0
- dwipe-3.0.0.dist-info/RECORD +24 -0
- dwipe/ToolManager.py +0 -637
- dwipe/WipeJobFuture.py +0 -245
- dwipe-2.0.1.dist-info/METADATA +0 -410
- dwipe-2.0.1.dist-info/RECORD +0 -14
- {dwipe-2.0.1.dist-info → dwipe-3.0.0.dist-info}/WHEEL +0 -0
- {dwipe-2.0.1.dist-info → dwipe-3.0.0.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.1.dist-info → dwipe-3.0.0.dist-info}/licenses/LICENSE +0 -0
dwipe/Utils.py
CHANGED
|
@@ -3,13 +3,28 @@ Utils class - Utility functions for dwipe
|
|
|
3
3
|
"""
|
|
4
4
|
import os
|
|
5
5
|
import sys
|
|
6
|
+
import pwd
|
|
6
7
|
import datetime
|
|
7
8
|
from pathlib import Path
|
|
9
|
+
from .StructuredLogger import StructuredLogger
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
class Utils:
|
|
11
13
|
"""Utility functions encapsulated as a family"""
|
|
12
14
|
|
|
15
|
+
# Singleton logger instance
|
|
16
|
+
_logger = None
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def get_logger():
|
|
20
|
+
"""Get or create the singleton StructuredLogger instance"""
|
|
21
|
+
if Utils._logger is None:
|
|
22
|
+
Utils._logger = StructuredLogger(
|
|
23
|
+
app_name='dwipe',
|
|
24
|
+
log_dir=Utils.get_config_dir()
|
|
25
|
+
)
|
|
26
|
+
return Utils._logger
|
|
27
|
+
|
|
13
28
|
@staticmethod
|
|
14
29
|
def human(number):
|
|
15
30
|
"""Return a concise number description."""
|
|
@@ -67,7 +82,6 @@ class Utils:
|
|
|
67
82
|
real_user = os.environ.get('SUDO_USER')
|
|
68
83
|
if real_user:
|
|
69
84
|
# Running with sudo - get the real user's home directory
|
|
70
|
-
import pwd
|
|
71
85
|
real_home = pwd.getpwnam(real_user).pw_dir
|
|
72
86
|
config_dir = Path(real_home) / '.config' / 'dwipe'
|
|
73
87
|
else:
|
|
@@ -79,7 +93,6 @@ class Utils:
|
|
|
79
93
|
# Fix ownership if running with sudo
|
|
80
94
|
if real_user:
|
|
81
95
|
try:
|
|
82
|
-
import pwd
|
|
83
96
|
pw_record = pwd.getpwnam(real_user)
|
|
84
97
|
uid, gid = pw_record.pw_uid, pw_record.pw_gid
|
|
85
98
|
os.chown(config_dir, uid, gid)
|
|
@@ -98,7 +111,6 @@ class Utils:
|
|
|
98
111
|
real_user = os.environ.get('SUDO_USER')
|
|
99
112
|
if real_user:
|
|
100
113
|
try:
|
|
101
|
-
import pwd
|
|
102
114
|
pw_record = pwd.getpwnam(real_user)
|
|
103
115
|
uid, gid = pw_record.pw_uid, pw_record.pw_gid
|
|
104
116
|
os.chown(file_path, uid, gid)
|
|
@@ -129,6 +141,111 @@ class Utils:
|
|
|
129
141
|
except Exception:
|
|
130
142
|
pass # Don't fail if log trimming fails
|
|
131
143
|
|
|
144
|
+
@staticmethod
|
|
145
|
+
def get_device_dict(partitions, partition):
|
|
146
|
+
"""Extract device information from partition namespace as dict
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
partition: Partition namespace object with device attributes
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
dict: Device information for structured logging
|
|
153
|
+
"""
|
|
154
|
+
if partition.name in partitions:
|
|
155
|
+
disk = partitions[partition.name]
|
|
156
|
+
while disk.parent:
|
|
157
|
+
disk = partitions[disk.parent]
|
|
158
|
+
else:
|
|
159
|
+
disk = partition
|
|
160
|
+
device_dict = {
|
|
161
|
+
"name": partition.name,
|
|
162
|
+
"path": f"/dev/{partition.name}",
|
|
163
|
+
"size": Utils.human(partition.size_bytes),
|
|
164
|
+
}
|
|
165
|
+
device_dict["uuid"] = partition.uuid
|
|
166
|
+
if partition.type:
|
|
167
|
+
device_dict["type"] = partition.type
|
|
168
|
+
if partition.fstype:
|
|
169
|
+
device_dict["fstype"] = partition.fstype
|
|
170
|
+
if partition.label:
|
|
171
|
+
device_dict["label"] = partition.label
|
|
172
|
+
|
|
173
|
+
device_dict["model"] = disk.model
|
|
174
|
+
device_dict["serial"] = disk.serial
|
|
175
|
+
device_dict["port"] = disk.port
|
|
176
|
+
|
|
177
|
+
return device_dict
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def log_wipe_structured(partitions, partition, job, mode=None):
|
|
181
|
+
"""Log a wipe or verify operation using structured logging
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
partition: Partition namespace object with device info
|
|
185
|
+
job: WipeJob object with job statistics
|
|
186
|
+
mode: Optional mode override (defaults to job.opts.wipe_mode)
|
|
187
|
+
"""
|
|
188
|
+
logger = Utils.get_logger()
|
|
189
|
+
|
|
190
|
+
# Determine log level based on result
|
|
191
|
+
is_verify_only = getattr(job, 'is_verify_only', False)
|
|
192
|
+
is_stopped = job.do_abort
|
|
193
|
+
|
|
194
|
+
if is_verify_only:
|
|
195
|
+
level = "VERIFY_STOPPED" if is_stopped else "VERIFY_COMPLETE"
|
|
196
|
+
else:
|
|
197
|
+
level = "WIPE_STOPPED" if is_stopped else "WIPE_COMPLETE"
|
|
198
|
+
|
|
199
|
+
# Get the three sections
|
|
200
|
+
plan = job.get_plan_dict(mode)
|
|
201
|
+
device = Utils.get_device_dict(partitions, partition)
|
|
202
|
+
summary = job.get_summary_dict()
|
|
203
|
+
|
|
204
|
+
# Create summary message
|
|
205
|
+
result_str = summary['result']
|
|
206
|
+
size_str = device['size']
|
|
207
|
+
time_str = summary['total_elapsed']
|
|
208
|
+
# Get rate from wipe step (skip precheck/verify steps for firmware wipes)
|
|
209
|
+
rate_str = 'N/A'
|
|
210
|
+
for step in summary['steps']:
|
|
211
|
+
step_name = step.get('step', '').lower()
|
|
212
|
+
# Look for actual wipe steps: firmware wipes start with "firmware",
|
|
213
|
+
# software wipes have "writing" in the step name
|
|
214
|
+
# Skip precheck, pre-verify, post-verify steps
|
|
215
|
+
if step_name.startswith('firmware') or 'writing' in step_name:
|
|
216
|
+
rate_str = step.get('rate', 'N/A')
|
|
217
|
+
break
|
|
218
|
+
# Fallback to first step if no wipe step found
|
|
219
|
+
if rate_str == 'N/A' and summary['steps']:
|
|
220
|
+
rate_str = summary['steps'][0].get('rate', 'N/A')
|
|
221
|
+
|
|
222
|
+
# Build base message
|
|
223
|
+
operation = plan['operation'].capitalize()
|
|
224
|
+
message = f"{operation} {result_str}: {device['name']} {size_str}"
|
|
225
|
+
|
|
226
|
+
# Add percentage if stopped
|
|
227
|
+
if result_str == 'stopped' and summary.get('pct_complete', 0) > 0:
|
|
228
|
+
message += f" ({summary['pct_complete']:.0f}%)"
|
|
229
|
+
|
|
230
|
+
# Add timing and rate
|
|
231
|
+
message += f" in {time_str} @ {rate_str}"
|
|
232
|
+
|
|
233
|
+
# Add error reason if present
|
|
234
|
+
abort_reason = summary.get('abort_reason')
|
|
235
|
+
if abort_reason:
|
|
236
|
+
message += f" [Error: {abort_reason}]"
|
|
237
|
+
|
|
238
|
+
# Log the structured event
|
|
239
|
+
logger.put(
|
|
240
|
+
level,
|
|
241
|
+
message,
|
|
242
|
+
data={
|
|
243
|
+
"plan": plan,
|
|
244
|
+
"device": device,
|
|
245
|
+
"summary": summary
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
|
|
132
249
|
@staticmethod
|
|
133
250
|
def log_wipe(device_name, size_bytes, mode, result, elapsed_time=None, uuid=None, label=None, fstype=None, pct=None, verify_result=None):
|
|
134
251
|
"""Log a wipe or verify operation to ~/.config/dwipe/log.txt
|
|
@@ -197,3 +314,181 @@ class Utils:
|
|
|
197
314
|
Utils.fix_file_ownership(log_path)
|
|
198
315
|
except Exception:
|
|
199
316
|
pass # Don't fail if logging fails
|
|
317
|
+
|
|
318
|
+
@staticmethod
|
|
319
|
+
def parse_nvme_sanitize_capabilities(id_ctrl_data: dict) -> dict:
|
|
320
|
+
"""Parse NVMe sanitize capabilities from id-ctrl JSON data.
|
|
321
|
+
|
|
322
|
+
Extracts the sanicap field and interprets the capability bits according
|
|
323
|
+
to the NVMe specification:
|
|
324
|
+
- Bit 0: Crypto Erase
|
|
325
|
+
- Bit 1: Block Erase
|
|
326
|
+
- Bit 2: Overwrite
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
id_ctrl_data: Dictionary from nvme id-ctrl command (with -o json)
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
dict: Maps capability names to method names, e.g.,
|
|
333
|
+
{'Crypto': 'sanitize_crypto', 'Block': 'sanitize_block'}
|
|
334
|
+
"""
|
|
335
|
+
modes = {}
|
|
336
|
+
sanicap = id_ctrl_data.get('sanicap', 0)
|
|
337
|
+
|
|
338
|
+
if sanicap > 0:
|
|
339
|
+
if sanicap & 0x01:
|
|
340
|
+
modes['Crypto'] = 'sanitize_crypto'
|
|
341
|
+
if sanicap & 0x02:
|
|
342
|
+
modes['Block'] = 'sanitize_block'
|
|
343
|
+
if sanicap & 0x04:
|
|
344
|
+
modes['Ovwr'] = 'sanitize_overwrite'
|
|
345
|
+
|
|
346
|
+
return modes
|
|
347
|
+
|
|
348
|
+
@staticmethod
|
|
349
|
+
def parse_nvme_sanitize_flags(id_ctrl_data: dict) -> tuple:
|
|
350
|
+
"""Parse NVMe sanitize capabilities into individual boolean flags.
|
|
351
|
+
|
|
352
|
+
Interprets sanicap bits according to NVMe specification:
|
|
353
|
+
- Bit 0: Crypto Erase
|
|
354
|
+
- Bit 1: Block Erase
|
|
355
|
+
- Bit 2: Overwrite
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
id_ctrl_data: Dictionary from nvme id-ctrl command (with -o json)
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
tuple: (has_sanitize, crypto_erase_supported, block_erase_supported,
|
|
362
|
+
overwrite_supported)
|
|
363
|
+
"""
|
|
364
|
+
sanicap = id_ctrl_data.get('sanicap', 0)
|
|
365
|
+
has_sanitize = sanicap > 0
|
|
366
|
+
crypto_erase_supported = bool(sanicap & 0x01)
|
|
367
|
+
block_erase_supported = bool(sanicap & 0x02)
|
|
368
|
+
overwrite_supported = bool(sanicap & 0x04)
|
|
369
|
+
|
|
370
|
+
return has_sanitize, crypto_erase_supported, block_erase_supported, overwrite_supported
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class ClipboardHelper:
|
|
374
|
+
"""Helper for copying text to clipboard across different Linux environments.
|
|
375
|
+
|
|
376
|
+
Supports:
|
|
377
|
+
- OSC 52 escape sequence (works over SSH if terminal supports it)
|
|
378
|
+
- Wayland (wl-copy)
|
|
379
|
+
- X11 (xclip, xsel)
|
|
380
|
+
- Terminal fallback (print to screen when clipboard unavailable)
|
|
381
|
+
|
|
382
|
+
Detection priority:
|
|
383
|
+
1. If SSH session detected -> use OSC 52 (terminal clipboard escape sequence)
|
|
384
|
+
2. If WAYLAND_DISPLAY set -> try wl-copy
|
|
385
|
+
3. If DISPLAY set -> try xclip, then xsel
|
|
386
|
+
4. Fallback -> terminal mode (manual copy)
|
|
387
|
+
"""
|
|
388
|
+
import subprocess
|
|
389
|
+
import shutil
|
|
390
|
+
|
|
391
|
+
# Singleton instance for caching probe results
|
|
392
|
+
_instance = None
|
|
393
|
+
_probed = False
|
|
394
|
+
_clipboard_cmd = None # e.g., ['wl-copy'] or ['xclip', '-selection', 'clipboard']
|
|
395
|
+
_use_osc52 = False # Use OSC 52 terminal escape sequence
|
|
396
|
+
_is_ssh = False
|
|
397
|
+
_method_name = None # Human-readable method name
|
|
398
|
+
|
|
399
|
+
@classmethod
|
|
400
|
+
def _probe(cls):
|
|
401
|
+
"""Probe available clipboard methods (called once, results cached)."""
|
|
402
|
+
if cls._probed:
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
cls._probed = True
|
|
406
|
+
import shutil
|
|
407
|
+
|
|
408
|
+
# Check for SSH session - use OSC 52 escape sequence
|
|
409
|
+
cls._is_ssh = bool(os.environ.get('SSH_CLIENT') or os.environ.get('SSH_TTY')
|
|
410
|
+
or os.environ.get('SSH_CONNECTION'))
|
|
411
|
+
|
|
412
|
+
if cls._is_ssh:
|
|
413
|
+
# OSC 52 works through terminal emulators that support it
|
|
414
|
+
# (iTerm2, kitty, alacritty, Windows Terminal, tmux with set-clipboard)
|
|
415
|
+
cls._use_osc52 = True
|
|
416
|
+
cls._method_name = 'OSC 52 (terminal escape sequence)'
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
# Check Wayland first
|
|
420
|
+
if os.environ.get('WAYLAND_DISPLAY'):
|
|
421
|
+
if shutil.which('wl-copy'):
|
|
422
|
+
cls._clipboard_cmd = ['wl-copy']
|
|
423
|
+
cls._method_name = 'wl-copy (Wayland)'
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
# Check X11
|
|
427
|
+
if os.environ.get('DISPLAY'):
|
|
428
|
+
if shutil.which('xclip'):
|
|
429
|
+
cls._clipboard_cmd = ['xclip', '-selection', 'clipboard']
|
|
430
|
+
cls._method_name = 'xclip (X11)'
|
|
431
|
+
return
|
|
432
|
+
if shutil.which('xsel'):
|
|
433
|
+
cls._clipboard_cmd = ['xsel', '--clipboard', '--input']
|
|
434
|
+
cls._method_name = 'xsel (X11)'
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
cls._method_name = 'terminal (no clipboard tool found)'
|
|
438
|
+
|
|
439
|
+
@classmethod
|
|
440
|
+
def get_method_name(cls):
|
|
441
|
+
"""Return human-readable description of clipboard method."""
|
|
442
|
+
cls._probe()
|
|
443
|
+
return cls._method_name
|
|
444
|
+
|
|
445
|
+
@classmethod
|
|
446
|
+
def has_clipboard(cls):
|
|
447
|
+
"""Return True if a clipboard method is available (tool or OSC 52)."""
|
|
448
|
+
cls._probe()
|
|
449
|
+
return cls._clipboard_cmd is not None or cls._use_osc52
|
|
450
|
+
|
|
451
|
+
@classmethod
|
|
452
|
+
def copy(cls, text):
|
|
453
|
+
"""Copy text to clipboard.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
text: String to copy
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
tuple: (success: bool, error_message: str or None)
|
|
460
|
+
"""
|
|
461
|
+
cls._probe()
|
|
462
|
+
import subprocess
|
|
463
|
+
import base64
|
|
464
|
+
|
|
465
|
+
# Try OSC 52 escape sequence (works over SSH with supporting terminals)
|
|
466
|
+
if cls._use_osc52:
|
|
467
|
+
try:
|
|
468
|
+
encoded = base64.b64encode(text.encode('utf-8')).decode('ascii')
|
|
469
|
+
# OSC 52: \033]52;c;<base64>\a
|
|
470
|
+
# 'c' = clipboard selection
|
|
471
|
+
osc52 = f'\033]52;c;{encoded}\a'
|
|
472
|
+
sys.stdout.write(osc52)
|
|
473
|
+
sys.stdout.flush()
|
|
474
|
+
return True, None
|
|
475
|
+
except Exception as e:
|
|
476
|
+
return False, f"OSC 52 failed: {e}"
|
|
477
|
+
|
|
478
|
+
if not cls._clipboard_cmd:
|
|
479
|
+
return False, "No clipboard available"
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
proc = subprocess.run(
|
|
483
|
+
cls._clipboard_cmd,
|
|
484
|
+
input=text.encode('utf-8'),
|
|
485
|
+
capture_output=True,
|
|
486
|
+
timeout=5
|
|
487
|
+
)
|
|
488
|
+
if proc.returncode == 0:
|
|
489
|
+
return True, None
|
|
490
|
+
return False, proc.stderr.decode('utf-8', errors='replace').strip()
|
|
491
|
+
except subprocess.TimeoutExpired:
|
|
492
|
+
return False, "Clipboard command timed out"
|
|
493
|
+
except Exception as e:
|
|
494
|
+
return False, str(e)
|