dwipe 1.0.7__py3-none-any.whl → 2.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/DeviceInfo.py +492 -0
- dwipe/DiskWipe.py +880 -0
- dwipe/PersistentState.py +195 -0
- dwipe/Utils.py +199 -0
- dwipe/WipeJob.py +1243 -0
- dwipe/WipeJobFuture.py +245 -0
- dwipe/main.py +19 -853
- dwipe-2.0.0.dist-info/METADATA +407 -0
- dwipe-2.0.0.dist-info/RECORD +13 -0
- dwipe-1.0.7.dist-info/METADATA +0 -72
- dwipe-1.0.7.dist-info/RECORD +0 -7
- {dwipe-1.0.7.dist-info → dwipe-2.0.0.dist-info}/WHEEL +0 -0
- {dwipe-1.0.7.dist-info → dwipe-2.0.0.dist-info}/entry_points.txt +0 -0
- {dwipe-1.0.7.dist-info → dwipe-2.0.0.dist-info}/licenses/LICENSE +0 -0
dwipe/PersistentState.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PersistentState class for saving user preferences and device states
|
|
3
|
+
"""
|
|
4
|
+
# pylint: disable=invalid-name,broad-exception-caught,line-too-long
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from .Utils import Utils
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PersistentState:
|
|
12
|
+
"""Manages persistent state for dwipe preferences and device locks"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, config_path=None):
|
|
15
|
+
"""Initialize persistent state
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
config_path: Path to config file (default: ~/.config/dwipe/state.json)
|
|
19
|
+
"""
|
|
20
|
+
if config_path is None:
|
|
21
|
+
config_dir = Utils.get_config_dir()
|
|
22
|
+
config_path = config_dir / 'state.json'
|
|
23
|
+
|
|
24
|
+
self.config_path = Path(config_path)
|
|
25
|
+
self.state = {
|
|
26
|
+
'theme': 'default',
|
|
27
|
+
'wipe_mode': 'Zero', # 'Rand' or 'Zero' or +V
|
|
28
|
+
'passes': 1, # 1, 2, or 4 wipe passes
|
|
29
|
+
'confirmation': 'YES', # 'Y', 'y', 'YES', 'yes', 'device'
|
|
30
|
+
'verify_pct': 2, # 0, 2, 5, 10, 25, 50, 100
|
|
31
|
+
'dense': False, # True = compact view, False = blank lines between disks
|
|
32
|
+
'slowdown_stop': 16,
|
|
33
|
+
'stall_timeout': 60,
|
|
34
|
+
'devices': {} # device_id -> {locked, last_seen, last_name, size_bytes}
|
|
35
|
+
}
|
|
36
|
+
self.dirty = False
|
|
37
|
+
self.max_devices = 400
|
|
38
|
+
|
|
39
|
+
self.load()
|
|
40
|
+
|
|
41
|
+
def save_updated_opts(self, opts):
|
|
42
|
+
"""Save updated option variables from opts to state"""
|
|
43
|
+
for key in self.state:
|
|
44
|
+
if key == 'devices':
|
|
45
|
+
continue # Skip devices dict
|
|
46
|
+
if hasattr(opts, key):
|
|
47
|
+
value = getattr(opts, key)
|
|
48
|
+
if self.state[key] != value:
|
|
49
|
+
self.state[key] = value
|
|
50
|
+
self.dirty = True
|
|
51
|
+
|
|
52
|
+
def restore_updated_opts(self, opts):
|
|
53
|
+
"""Restore option variables from state to opts"""
|
|
54
|
+
for key in self.state:
|
|
55
|
+
if hasattr(opts, key):
|
|
56
|
+
setattr(opts, key, self.state[key])
|
|
57
|
+
|
|
58
|
+
def load(self):
|
|
59
|
+
"""Load state from disk"""
|
|
60
|
+
if self.config_path.exists():
|
|
61
|
+
try:
|
|
62
|
+
with open(self.config_path, 'r', encoding='utf-8') as f:
|
|
63
|
+
loaded = json.load(f)
|
|
64
|
+
# Migrate old mode values to new format
|
|
65
|
+
# if 'mode' in loaded:
|
|
66
|
+
# old_mode = loaded['mode']
|
|
67
|
+
# if old_mode in ('random', 'Random', 'RANDOM'):
|
|
68
|
+
# loaded['mode'] = 'Rand'
|
|
69
|
+
# self.dirty = True # Save migrated value
|
|
70
|
+
# elif old_mode in ('zero', 'zeros', 'Zero', 'ZERO'):
|
|
71
|
+
# loaded['mode'] = 'Zero'
|
|
72
|
+
# self.dirty = True # Save migrated value
|
|
73
|
+
self.state.update(loaded)
|
|
74
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
75
|
+
print(f'Warning: Could not load state from {self.config_path}: {e}')
|
|
76
|
+
|
|
77
|
+
def save(self):
|
|
78
|
+
"""Save state to disk if dirty"""
|
|
79
|
+
if not self.dirty:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
# Clean up old devices before saving
|
|
84
|
+
self._cleanup_old_devices()
|
|
85
|
+
|
|
86
|
+
with open(self.config_path, 'w', encoding='utf-8') as f:
|
|
87
|
+
json.dump(self.state, f, indent=2)
|
|
88
|
+
# Fix ownership if running with sudo
|
|
89
|
+
Utils.fix_file_ownership(self.config_path)
|
|
90
|
+
self.dirty = False
|
|
91
|
+
except IOError as e:
|
|
92
|
+
print(f'Warning: Could not save state to {self.config_path}: {e}')
|
|
93
|
+
|
|
94
|
+
def _cleanup_old_devices(self):
|
|
95
|
+
"""Keep only the most recent max_devices entries"""
|
|
96
|
+
devices = self.state['devices']
|
|
97
|
+
if len(devices) <= self.max_devices:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
# Sort by last_seen timestamp, keep most recent
|
|
101
|
+
sorted_devices = sorted(
|
|
102
|
+
devices.items(),
|
|
103
|
+
key=lambda x: x[1].get('last_seen', 0),
|
|
104
|
+
reverse=True
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Keep only max_devices most recent
|
|
108
|
+
self.state['devices'] = dict(sorted_devices[:self.max_devices])
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def make_device_id(partition):
|
|
112
|
+
"""Create a stable device identifier
|
|
113
|
+
|
|
114
|
+
Priority:
|
|
115
|
+
1. partition.uuid (PARTUUID for partitions, UUID for filesystems)
|
|
116
|
+
2. partition.serial (for whole disks)
|
|
117
|
+
3. Fallback: hash of name+size+label+fstype
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
partition: SimpleNamespace with device info
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
str: Stable device identifier
|
|
124
|
+
"""
|
|
125
|
+
# Try UUID first (PARTUUID or filesystem UUID)
|
|
126
|
+
if hasattr(partition, 'uuid') and partition.uuid:
|
|
127
|
+
return f'uuid:{partition.uuid}'
|
|
128
|
+
|
|
129
|
+
# Try serial number (for whole disks)
|
|
130
|
+
if hasattr(partition, 'serial') and partition.serial:
|
|
131
|
+
return f'serial:{partition.serial}'
|
|
132
|
+
|
|
133
|
+
# Fallback: create stable ID from device characteristics
|
|
134
|
+
# This will break if device is repartitioned/reformatted, but that's acceptable
|
|
135
|
+
parts = [
|
|
136
|
+
partition.name,
|
|
137
|
+
str(partition.size_bytes),
|
|
138
|
+
partition.label if hasattr(partition, 'label') else '',
|
|
139
|
+
partition.fstype if hasattr(partition, 'fstype') else ''
|
|
140
|
+
]
|
|
141
|
+
fallback_id = ':'.join(parts)
|
|
142
|
+
return f'fallback:{fallback_id}'
|
|
143
|
+
|
|
144
|
+
def get_device_locked(self, partition):
|
|
145
|
+
"""Check if a device is locked
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
partition: SimpleNamespace with device info
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
bool: True if device is locked
|
|
152
|
+
"""
|
|
153
|
+
device_id = self.make_device_id(partition)
|
|
154
|
+
device_state = self.state['devices'].get(device_id, {})
|
|
155
|
+
return device_state.get('locked', False)
|
|
156
|
+
|
|
157
|
+
def set_device_locked(self, partition, locked):
|
|
158
|
+
"""Set device lock state
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
partition: SimpleNamespace with device info
|
|
162
|
+
locked: bool, True to lock device
|
|
163
|
+
"""
|
|
164
|
+
device_id = self.make_device_id(partition)
|
|
165
|
+
now = int(time.time())
|
|
166
|
+
|
|
167
|
+
if device_id not in self.state['devices']:
|
|
168
|
+
self.state['devices'][device_id] = {}
|
|
169
|
+
|
|
170
|
+
device_state = self.state['devices'][device_id]
|
|
171
|
+
device_state['locked'] = locked
|
|
172
|
+
device_state['last_seen'] = now
|
|
173
|
+
device_state['last_name'] = partition.name
|
|
174
|
+
device_state['size_bytes'] = partition.size_bytes
|
|
175
|
+
|
|
176
|
+
self.dirty = True
|
|
177
|
+
|
|
178
|
+
def update_device_seen(self, partition):
|
|
179
|
+
"""Update last_seen timestamp for a device
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
partition: SimpleNamespace with device info
|
|
183
|
+
"""
|
|
184
|
+
device_id = self.make_device_id(partition)
|
|
185
|
+
now = int(time.time())
|
|
186
|
+
|
|
187
|
+
if device_id in self.state['devices']:
|
|
188
|
+
self.state['devices'][device_id]['last_seen'] = now
|
|
189
|
+
self.state['devices'][device_id]['last_name'] = partition.name
|
|
190
|
+
self.dirty = True
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def sync(self):
|
|
194
|
+
"""Save state if dirty (called each loop)"""
|
|
195
|
+
self.save()
|
dwipe/Utils.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utils class - Utility functions for dwipe
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Utils:
|
|
11
|
+
"""Utility functions encapsulated as a family"""
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def human(number):
|
|
15
|
+
"""Return a concise number description."""
|
|
16
|
+
suffixes = ['K', 'M', 'G', 'T']
|
|
17
|
+
number = float(number)
|
|
18
|
+
while suffixes:
|
|
19
|
+
suffix = suffixes.pop(0)
|
|
20
|
+
number /= 1000 # decimal
|
|
21
|
+
if number < 999.95 or not suffixes:
|
|
22
|
+
return f'{number:.1f}{suffix}B' # decimal
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def ago_str(delta_secs, signed=False):
|
|
27
|
+
"""Turn time differences in seconds to a compact representation;
|
|
28
|
+
e.g., '18h·39m' or '450ms' for sub-second times
|
|
29
|
+
"""
|
|
30
|
+
abs_delta = delta_secs if delta_secs >= 0 else -delta_secs
|
|
31
|
+
|
|
32
|
+
# For sub-second times, show milliseconds
|
|
33
|
+
if 0.00051 <= abs_delta < 0.99949:
|
|
34
|
+
ms = int(abs_delta * 1000)
|
|
35
|
+
rv = '-' if signed and delta_secs < 0 else ''
|
|
36
|
+
return rv + f'{ms}ms'
|
|
37
|
+
|
|
38
|
+
ago = int(max(0, round(abs_delta)))
|
|
39
|
+
divs = (60, 60, 24, 7, 52, 9999999)
|
|
40
|
+
units = ('s', 'm', 'h', 'd', 'w', 'y')
|
|
41
|
+
vals = (ago % 60, int(ago / 60)) # seed with secs, mins (step til 2nd fits)
|
|
42
|
+
uidx = 1 # best units
|
|
43
|
+
for div in divs[1:]:
|
|
44
|
+
if vals[1] < div:
|
|
45
|
+
break
|
|
46
|
+
vals = (vals[1] % div, int(vals[1] / div))
|
|
47
|
+
uidx += 1
|
|
48
|
+
rv = '-' if signed and delta_secs < 0 else ''
|
|
49
|
+
rv += f'{vals[1]}{units[uidx]}' if vals[1] else ''
|
|
50
|
+
rv += f'{vals[0]:d}{units[uidx - 1]}'
|
|
51
|
+
return rv
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def rerun_module_as_root(module_name):
|
|
55
|
+
"""Rerun using the module name"""
|
|
56
|
+
if os.geteuid() != 0: # Re-run the script with sudo
|
|
57
|
+
os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
58
|
+
vp = ['sudo', sys.executable, '-m', module_name] + sys.argv[1:]
|
|
59
|
+
os.execvp('sudo', vp)
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def get_config_dir():
|
|
63
|
+
"""Get the dwipe config directory, handling sudo correctly
|
|
64
|
+
|
|
65
|
+
Returns the real user's ~/.config/dwipe directory, even when running with sudo
|
|
66
|
+
"""
|
|
67
|
+
real_user = os.environ.get('SUDO_USER')
|
|
68
|
+
if real_user:
|
|
69
|
+
# Running with sudo - get the real user's home directory
|
|
70
|
+
import pwd
|
|
71
|
+
real_home = pwd.getpwnam(real_user).pw_dir
|
|
72
|
+
config_dir = Path(real_home) / '.config' / 'dwipe'
|
|
73
|
+
else:
|
|
74
|
+
# Not running with sudo - use normal home
|
|
75
|
+
config_dir = Path.home() / '.config' / 'dwipe'
|
|
76
|
+
|
|
77
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
|
|
79
|
+
# Fix ownership if running with sudo
|
|
80
|
+
if real_user:
|
|
81
|
+
try:
|
|
82
|
+
import pwd
|
|
83
|
+
pw_record = pwd.getpwnam(real_user)
|
|
84
|
+
uid, gid = pw_record.pw_uid, pw_record.pw_gid
|
|
85
|
+
os.chown(config_dir, uid, gid)
|
|
86
|
+
# Also fix parent .config directory if we created it
|
|
87
|
+
parent = config_dir.parent
|
|
88
|
+
if parent.exists():
|
|
89
|
+
os.chown(parent, uid, gid)
|
|
90
|
+
except (OSError, KeyError):
|
|
91
|
+
pass # Ignore permission errors and missing users
|
|
92
|
+
|
|
93
|
+
return config_dir
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def fix_file_ownership(file_path):
|
|
97
|
+
"""Fix file ownership to the real user when running with sudo"""
|
|
98
|
+
real_user = os.environ.get('SUDO_USER')
|
|
99
|
+
if real_user:
|
|
100
|
+
try:
|
|
101
|
+
import pwd
|
|
102
|
+
pw_record = pwd.getpwnam(real_user)
|
|
103
|
+
uid, gid = pw_record.pw_uid, pw_record.pw_gid
|
|
104
|
+
os.chown(file_path, uid, gid)
|
|
105
|
+
except (OSError, KeyError):
|
|
106
|
+
pass # Ignore permission errors and missing users
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def get_log_path():
|
|
110
|
+
"""Get the path to the log file, creating directory if needed"""
|
|
111
|
+
log_dir = Utils.get_config_dir()
|
|
112
|
+
return log_dir / 'log.txt'
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def trim_log_if_needed(log_path, max_lines=1000):
|
|
116
|
+
"""Trim log file by removing oldest 1/3 if it exceeds max_lines"""
|
|
117
|
+
try:
|
|
118
|
+
if not log_path.exists():
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
with open(log_path, 'r', encoding='utf-8') as f:
|
|
122
|
+
lines = f.readlines()
|
|
123
|
+
|
|
124
|
+
if len(lines) > max_lines:
|
|
125
|
+
# Keep the newest 2/3 of the log
|
|
126
|
+
keep_count = len(lines) * 2 // 3
|
|
127
|
+
with open(log_path, 'w', encoding='utf-8') as f:
|
|
128
|
+
f.writelines(lines[-keep_count:])
|
|
129
|
+
except Exception:
|
|
130
|
+
pass # Don't fail if log trimming fails
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def log_wipe(device_name, size_bytes, mode, result, elapsed_time=None, uuid=None, label=None, fstype=None, pct=None, verify_result=None):
|
|
134
|
+
"""Log a wipe or verify operation to ~/.config/dwipe/log.txt
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
device_name: Device name (e.g., 'sdb1')
|
|
138
|
+
size_bytes: Size of device in bytes
|
|
139
|
+
mode: 'Rand', 'Zero', or 'Vrfy' (for verify operations)
|
|
140
|
+
result: 'completed' or 'stopped'
|
|
141
|
+
elapsed_time: Optional elapsed time in seconds
|
|
142
|
+
uuid: Optional UUID of the partition
|
|
143
|
+
label: Optional label of the partition (only for non-wiped)
|
|
144
|
+
fstype: Optional filesystem type (only for non-wiped)
|
|
145
|
+
pct: Optional percentage (for stopped wipes)
|
|
146
|
+
verify_result: Optional verify result (chi-squared value for Rand verifies)
|
|
147
|
+
"""
|
|
148
|
+
log_path = Utils.get_log_path()
|
|
149
|
+
|
|
150
|
+
# Trim log if needed before appending
|
|
151
|
+
Utils.trim_log_if_needed(log_path)
|
|
152
|
+
|
|
153
|
+
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
|
|
154
|
+
size_str = Utils.human(size_bytes)
|
|
155
|
+
time_str = f' in {Utils.ago_str(int(elapsed_time))}' if elapsed_time else ''
|
|
156
|
+
|
|
157
|
+
# Show percentage if available, otherwise 100% for completed or result status
|
|
158
|
+
if result == 'completed':
|
|
159
|
+
status_str = '100%'
|
|
160
|
+
elif result in ('OK', 'FAIL', 'skip'):
|
|
161
|
+
# For verify operations: show 100% and the result
|
|
162
|
+
status_str = f'100%'
|
|
163
|
+
elif pct is not None:
|
|
164
|
+
status_str = f'{pct:3d}%'
|
|
165
|
+
else:
|
|
166
|
+
status_str = f'{result:>4s}'
|
|
167
|
+
|
|
168
|
+
# Build UUID field (last 8 chars or full if shorter)
|
|
169
|
+
uuid_str = uuid[-8:] if uuid and len(uuid) >= 8 else (uuid if uuid else '-')
|
|
170
|
+
|
|
171
|
+
# For wiped/verified disks, don't show label/fstype (they shouldn't have any)
|
|
172
|
+
# Only show for non-completed operations
|
|
173
|
+
if (result in ('completed', 'OK', 'FAIL', 'skip')) and mode in ('Rand', 'Zero', 'Vrfy'):
|
|
174
|
+
info_str = ''
|
|
175
|
+
else:
|
|
176
|
+
# Show fstype and label for non-wiped disks
|
|
177
|
+
fstype_str = fstype if fstype and fstype.strip() else '-'
|
|
178
|
+
label_str = f"'{label}'" if label and label.strip() else "'-'"
|
|
179
|
+
info_str = f' | {fstype_str} {label_str}'
|
|
180
|
+
|
|
181
|
+
# Add result status for verify operations (OK/FAIL)
|
|
182
|
+
result_str = ''
|
|
183
|
+
if result in ('OK', 'FAIL', 'skip'):
|
|
184
|
+
result_str = f' | {result}'
|
|
185
|
+
|
|
186
|
+
# Add verify result details if available (failure reason or stats)
|
|
187
|
+
stats_str = ''
|
|
188
|
+
if verify_result:
|
|
189
|
+
stats_str = f' | {verify_result}'
|
|
190
|
+
|
|
191
|
+
log_line = f'{timestamp} | {mode:4s} | {status_str} {size_str:>8s}{time_str:>12s} | {device_name:8s} | {uuid_str:8s}{info_str}{result_str}{stats_str}\n'
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
with open(log_path, 'a', encoding='utf-8') as f:
|
|
195
|
+
f.write(log_line)
|
|
196
|
+
# Fix ownership if running with sudo
|
|
197
|
+
Utils.fix_file_ownership(log_path)
|
|
198
|
+
except Exception:
|
|
199
|
+
pass # Don't fail if logging fails
|