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.
@@ -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