dwipe 1.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/PowerWindow.py +740 -0
- dwipe/__init__.py +0 -0
- dwipe/main.py +806 -0
- dwipe-1.0.0.dist-info/LICENSE +21 -0
- dwipe-1.0.0.dist-info/METADATA +67 -0
- dwipe-1.0.0.dist-info/RECORD +9 -0
- dwipe-1.0.0.dist-info/WHEEL +5 -0
- dwipe-1.0.0.dist-info/entry_points.txt +2 -0
- dwipe-1.0.0.dist-info/top_level.txt +1 -0
dwipe/main.py
ADDED
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
""" TBD
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
# pylint: disable=too-many-branches,too-many-statements,import-outside-toplevel
|
|
7
|
+
# pylint: disable=too-many-instance-attributes,invalid-name
|
|
8
|
+
# pylint: disable=broad-exception-caught,consider-using-with
|
|
9
|
+
# pylint: disable=too-many-return-statements,too-many-locals
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from fnmatch import fnmatch
|
|
13
|
+
import sys
|
|
14
|
+
import re
|
|
15
|
+
import json
|
|
16
|
+
import subprocess
|
|
17
|
+
import time
|
|
18
|
+
import threading
|
|
19
|
+
import random
|
|
20
|
+
import shutil
|
|
21
|
+
import traceback
|
|
22
|
+
import curses as cs
|
|
23
|
+
from types import SimpleNamespace
|
|
24
|
+
from typing import Tuple, List
|
|
25
|
+
from dwipe.PowerWindow import Window, OptionSpinner
|
|
26
|
+
|
|
27
|
+
def human(number):
|
|
28
|
+
""" Return a concise number description."""
|
|
29
|
+
suffixes = ['K', 'M', 'G', 'T']
|
|
30
|
+
number = float(number)
|
|
31
|
+
while suffixes:
|
|
32
|
+
suffix = suffixes.pop(0)
|
|
33
|
+
number /= 1024
|
|
34
|
+
if number < 99.95 or not suffixes:
|
|
35
|
+
return f'{number:.1f}{suffix}'
|
|
36
|
+
return None
|
|
37
|
+
##############################################################################
|
|
38
|
+
def ago_str(delta_secs, signed=False):
|
|
39
|
+
""" Turn time differences in seconds to a compact representation;
|
|
40
|
+
e.g., '18h·39m'
|
|
41
|
+
"""
|
|
42
|
+
ago = int(max(0, round(delta_secs if delta_secs >= 0 else -delta_secs)))
|
|
43
|
+
divs = (60, 60, 24, 7, 52, 9999999)
|
|
44
|
+
units = ('s', 'm', 'h', 'd', 'w', 'y')
|
|
45
|
+
vals = (ago%60, int(ago/60)) # seed with secs, mins (step til 2nd fits)
|
|
46
|
+
uidx = 1 # best units
|
|
47
|
+
for div in divs[1:]:
|
|
48
|
+
# print('vals', vals, 'div', div)
|
|
49
|
+
if vals[1] < div:
|
|
50
|
+
break
|
|
51
|
+
vals = (vals[1]%div, int(vals[1]/div))
|
|
52
|
+
uidx += 1
|
|
53
|
+
rv = '-' if signed and delta_secs < 0 else ''
|
|
54
|
+
rv += f'{vals[1]}{units[uidx]}' if vals[1] else ''
|
|
55
|
+
rv += f'{vals[0]:d}{units[uidx-1]}'
|
|
56
|
+
return rv
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class WipeJob:
|
|
60
|
+
""" TBD """
|
|
61
|
+
|
|
62
|
+
# Generate a 1MB buffer of random data
|
|
63
|
+
BUFFER_SIZE = 1 * 1024 * 1024 # 1MB
|
|
64
|
+
WRITE_SIZE = 16 * 1024 # 16KB
|
|
65
|
+
buffer = bytearray(os.urandom(BUFFER_SIZE))
|
|
66
|
+
zero_buffer = bytes(WRITE_SIZE)
|
|
67
|
+
|
|
68
|
+
# Shared status string
|
|
69
|
+
|
|
70
|
+
def __init__(self, device_path, total_size, opts=None):
|
|
71
|
+
self.opts = opts if opts else SimpleNamespace(dry_run=False)
|
|
72
|
+
self.device_path = device_path
|
|
73
|
+
self.total_size = total_size
|
|
74
|
+
self.do_abort = False
|
|
75
|
+
self.thread = None
|
|
76
|
+
|
|
77
|
+
self.start_mono = time.monotonic() # Track the start time
|
|
78
|
+
self.total_written = 0
|
|
79
|
+
self.wr_hists = [] # list of (mono, written)
|
|
80
|
+
self.done = False
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def start_job(device_path, total_size, opts):
|
|
84
|
+
""" TBD """
|
|
85
|
+
job = WipeJob(device_path=device_path, total_size=total_size, opts=opts)
|
|
86
|
+
job.thread = threading.Thread(target=job.write_partition)
|
|
87
|
+
job.wr_hists.append(SimpleNamespace(mono=time.monotonic(), written=0))
|
|
88
|
+
job.thread.start()
|
|
89
|
+
return job
|
|
90
|
+
|
|
91
|
+
def get_status_str(self):
|
|
92
|
+
""" TBD """
|
|
93
|
+
elapsed_time = time.monotonic() - self.start_mono
|
|
94
|
+
write_rate = self.total_written / elapsed_time if elapsed_time > 0 else 0
|
|
95
|
+
percent_complete = (self.total_written / self.total_size) * 100
|
|
96
|
+
return (f"Write rate: {write_rate / (1024 * 1024):.2f} MB/s, "
|
|
97
|
+
f"Completed: {percent_complete:.2f}%")
|
|
98
|
+
|
|
99
|
+
def get_status(self):
|
|
100
|
+
""" TBD """
|
|
101
|
+
pct_str, rate_str, when_str = '', '', ''
|
|
102
|
+
mono = time.monotonic()
|
|
103
|
+
written = self.total_written
|
|
104
|
+
elapsed_time = mono - self.start_mono
|
|
105
|
+
|
|
106
|
+
pct = (self.total_written / self.total_size) * 100
|
|
107
|
+
pct_str = f'{int(round(pct))}%'
|
|
108
|
+
if self.do_abort:
|
|
109
|
+
pct_str = 'STOP'
|
|
110
|
+
|
|
111
|
+
self.wr_hists.append(SimpleNamespace(mono=mono, written=written))
|
|
112
|
+
floor = mono - 30 # 30w moving average
|
|
113
|
+
while len(self.wr_hists) >= 3 and self.wr_hists[1].mono >= floor:
|
|
114
|
+
del self.wr_hists[0]
|
|
115
|
+
delta_mono = mono - self.wr_hists[0].mono
|
|
116
|
+
rate = (written - self.wr_hists[0].written) / delta_mono if delta_mono > 1.0 else 0
|
|
117
|
+
rate_str = f'{human(int(round(rate, 0)))}/s'
|
|
118
|
+
|
|
119
|
+
if rate > 0:
|
|
120
|
+
when = int(round((self.total_size - self.total_written)/rate))
|
|
121
|
+
when_str = ago_str(when)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
return ago_str(int(round(elapsed_time))), pct_str, rate_str, when_str
|
|
125
|
+
|
|
126
|
+
def write_partition(self):
|
|
127
|
+
"""Writes random chunks to a device and updates the progress status."""
|
|
128
|
+
self.total_written = 0 # Track total bytes written
|
|
129
|
+
is_random = self.opts.random
|
|
130
|
+
|
|
131
|
+
with open(self.device_path, 'wb') as device:
|
|
132
|
+
# for loop in range(10000000000):
|
|
133
|
+
offset = 0
|
|
134
|
+
chunk = memoryview(WipeJob.zero_buffer)
|
|
135
|
+
while True:
|
|
136
|
+
if self.do_abort:
|
|
137
|
+
break
|
|
138
|
+
if is_random:
|
|
139
|
+
offset = random.randint(0, WipeJob.BUFFER_SIZE - WipeJob.WRITE_SIZE)
|
|
140
|
+
# Use memoryview to avoid copying the data
|
|
141
|
+
chunk = memoryview(WipeJob.buffer)[offset:offset + WipeJob.WRITE_SIZE]
|
|
142
|
+
|
|
143
|
+
if self.opts.dry_run:
|
|
144
|
+
bytes_written = self.total_size // 120
|
|
145
|
+
time.sleep(0.25)
|
|
146
|
+
else:
|
|
147
|
+
try:
|
|
148
|
+
bytes_written = device.write(chunk)
|
|
149
|
+
except Exception:
|
|
150
|
+
bytes_written = 0
|
|
151
|
+
self.total_written += bytes_written
|
|
152
|
+
# Optional: Check for errors or incomplete writes
|
|
153
|
+
if bytes_written < WipeJob.WRITE_SIZE:
|
|
154
|
+
break
|
|
155
|
+
if self.opts.dry_run and self.total_written >= self.total_size:
|
|
156
|
+
break
|
|
157
|
+
# clear the beginning of device whether aborted or not
|
|
158
|
+
# if we have started writing
|
|
159
|
+
if not self.opts.dry_run and self.total_written > 0 and is_random:
|
|
160
|
+
device.seek(0)
|
|
161
|
+
chunk = memoryview(WipeJob.zero_buffer)
|
|
162
|
+
bytes_written = device.write(chunk)
|
|
163
|
+
self.done = True
|
|
164
|
+
|
|
165
|
+
class DeviceInfo:
|
|
166
|
+
""" Class to dig out the info we want from the system."""
|
|
167
|
+
disk_majors = set() # major devices that are disks
|
|
168
|
+
|
|
169
|
+
def __init__(self, opts):
|
|
170
|
+
self.opts = opts
|
|
171
|
+
self.DB = opts.debug
|
|
172
|
+
self.wids = None
|
|
173
|
+
self.head_str = None
|
|
174
|
+
self.partitions = None
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _make_partition_namespace(major, name, size_bytes, dflt):
|
|
178
|
+
return SimpleNamespace(name=name, # /proc/partitions
|
|
179
|
+
major=major, # /proc/partitions
|
|
180
|
+
# minor=minor, # /proc/partitions
|
|
181
|
+
parent=None, # a partition
|
|
182
|
+
state=dflt, # run-time state
|
|
183
|
+
dflt=dflt, # default run-time state
|
|
184
|
+
label='', # blkid
|
|
185
|
+
fstype='', # blkid
|
|
186
|
+
# fsuse='-',
|
|
187
|
+
size_bytes=size_bytes, # /sys/block/{name}/...
|
|
188
|
+
mounts=[], # /proc/mounts
|
|
189
|
+
minors=[],
|
|
190
|
+
job=None, # if zap running
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def parse_lsblk(self, dflt):
|
|
194
|
+
""" Parse ls_blk for all the goodies we need """
|
|
195
|
+
def eat_one(device):
|
|
196
|
+
entry = self._make_partition_namespace(0, '', '', dflt)
|
|
197
|
+
entry.name=device.get('name', '')
|
|
198
|
+
maj_min=device.get('maj:min', (-1,-1))
|
|
199
|
+
wds = maj_min.split(':', maxsplit=1)
|
|
200
|
+
entry.major = -1
|
|
201
|
+
if len(wds) > 0:
|
|
202
|
+
entry.major = int(wds[0])
|
|
203
|
+
entry.fstype = device.get('fstype', '')
|
|
204
|
+
if entry.fstype is None:
|
|
205
|
+
entry.fstype = ''
|
|
206
|
+
entry.label = device.get('label', '')
|
|
207
|
+
if not entry.label:
|
|
208
|
+
entry.label=device.get('partlabel', '')
|
|
209
|
+
if entry.label is None:
|
|
210
|
+
entry.label = ''
|
|
211
|
+
# entry.fsuse=device.get('fsuse%', '')
|
|
212
|
+
# if entry.fsuse is None:
|
|
213
|
+
# entry.fsuse = '-'
|
|
214
|
+
entry.size_bytes=int(device.get('size', 0))
|
|
215
|
+
mounts = device.get('mountpoints', [])
|
|
216
|
+
while len(mounts) >= 1 and mounts[0] is None:
|
|
217
|
+
del mounts[0]
|
|
218
|
+
entry.mounts = mounts
|
|
219
|
+
return entry
|
|
220
|
+
|
|
221
|
+
# Run the `lsblk` command and get its output in JSON format with additional columns
|
|
222
|
+
result = subprocess.run(['lsblk', '-J', '--bytes', '-o',
|
|
223
|
+
'NAME,MAJ:MIN,FSTYPE,LABEL,PARTLABEL,FSUSE%,SIZE,MOUNTPOINTS', ],
|
|
224
|
+
stdout=subprocess.PIPE, text=True, check=False)
|
|
225
|
+
parsed_data = json.loads(result.stdout)
|
|
226
|
+
entries = {}
|
|
227
|
+
|
|
228
|
+
# Parse each block device and its properties
|
|
229
|
+
for device in parsed_data['blockdevices']:
|
|
230
|
+
parent = eat_one(device)
|
|
231
|
+
entries[parent.name] = parent
|
|
232
|
+
for child in device.get('children', []):
|
|
233
|
+
entry = eat_one(child)
|
|
234
|
+
entries[entry.name] = entry
|
|
235
|
+
entry.parent = parent.name
|
|
236
|
+
parent.minors.append(entry.name)
|
|
237
|
+
if not parent.fstype:
|
|
238
|
+
parent.fstype = 'DISK'
|
|
239
|
+
self.disk_majors.add(entry.major)
|
|
240
|
+
if entry.mounts:
|
|
241
|
+
entry.state = 'Mnt'
|
|
242
|
+
parent.state = 'Mnt'
|
|
243
|
+
|
|
244
|
+
if self.DB:
|
|
245
|
+
print('\n\nDB: --->>> after parse_lsblk:')
|
|
246
|
+
for entry in entries.values():
|
|
247
|
+
print(vars(entry))
|
|
248
|
+
|
|
249
|
+
return entries
|
|
250
|
+
|
|
251
|
+
@staticmethod
|
|
252
|
+
def set_one_state(nss, ns, to=None, test_to=None):
|
|
253
|
+
""" Optionally, update a state, and always set inferred states
|
|
254
|
+
"""
|
|
255
|
+
ready_states = ('s', 'W', '-', '^')
|
|
256
|
+
job_states = ('*%', 'STOP')
|
|
257
|
+
inferred_states = ('Busy', 'Mnt', )
|
|
258
|
+
# other_states = ('Lock', 'Unlk')
|
|
259
|
+
|
|
260
|
+
def state_in(to, states):
|
|
261
|
+
return to in states or fnmatch(to, states[0])
|
|
262
|
+
|
|
263
|
+
to = test_to if test_to else to
|
|
264
|
+
|
|
265
|
+
parent, minors = None, []
|
|
266
|
+
if ns.parent:
|
|
267
|
+
parent = nss.get(ns.parent)
|
|
268
|
+
for minor in ns.minors:
|
|
269
|
+
minor_ns = nss.get(minor, None)
|
|
270
|
+
if minor_ns:
|
|
271
|
+
minors.append(minor_ns)
|
|
272
|
+
|
|
273
|
+
if to == 'STOP' and not state_in(ns.state, job_states):
|
|
274
|
+
return False
|
|
275
|
+
if to == 'Lock' and (not state_in(ns.state, list(ready_states) + ['Mnt'])
|
|
276
|
+
or ns.parent):
|
|
277
|
+
return False
|
|
278
|
+
if to == 'Unlk' and ns.state != 'Lock':
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
if to and fnmatch(to, '*%'):
|
|
282
|
+
if not state_in(ns.state, ready_states):
|
|
283
|
+
return False
|
|
284
|
+
for minor in minors:
|
|
285
|
+
if not state_in(minor.state, ready_states):
|
|
286
|
+
return False
|
|
287
|
+
elif to in ('s', 'W') and not state_in(ns.state, job_states):
|
|
288
|
+
return False
|
|
289
|
+
if test_to:
|
|
290
|
+
return True
|
|
291
|
+
|
|
292
|
+
if to is not None:
|
|
293
|
+
ns.state = to
|
|
294
|
+
|
|
295
|
+
# Here we set inferences that block starting jobs
|
|
296
|
+
# -- clearing these states will be done on the device
|
|
297
|
+
# refresh
|
|
298
|
+
if parent and state_in(ns.state, inferred_states):
|
|
299
|
+
if parent.state != 'Lock':
|
|
300
|
+
parent.state = ns.state
|
|
301
|
+
if state_in(ns.state, job_states):
|
|
302
|
+
if parent:
|
|
303
|
+
parent.state = 'Busy'
|
|
304
|
+
for minor in minors:
|
|
305
|
+
minor.state = 'Busy'
|
|
306
|
+
return True
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def set_all_states(nss):
|
|
310
|
+
""" Set every state per linkage inferences """
|
|
311
|
+
for ns in nss.values():
|
|
312
|
+
DeviceInfo.set_one_state(nss, ns)
|
|
313
|
+
|
|
314
|
+
def get_disk_partitions(self, nss):
|
|
315
|
+
""" Determine which partitions we want some are bogus like zram """
|
|
316
|
+
|
|
317
|
+
def whitelisted(device_name):
|
|
318
|
+
"""Check if device_name matches any pattern in whitelist
|
|
319
|
+
which are the disk devices."""
|
|
320
|
+
WHITELIST = ['nvme*', 'sd*', 'hd*', 'mmcblk*']
|
|
321
|
+
for pattern in WHITELIST:
|
|
322
|
+
if fnmatch(device_name, pattern):
|
|
323
|
+
return True
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
def blacklisted(device_name):
|
|
327
|
+
"""Check if device_name matches any pattern in black list
|
|
328
|
+
which are know not to be physical disks."""
|
|
329
|
+
BLACKLIST = ['zram*', 'ram*', 'dm-*', 'loop*', 'sr*']
|
|
330
|
+
for pattern in BLACKLIST:
|
|
331
|
+
if fnmatch(device_name, pattern):
|
|
332
|
+
return 'blkLst'
|
|
333
|
+
return ''
|
|
334
|
+
|
|
335
|
+
def writable(device_name):
|
|
336
|
+
"""Check if the device is writable."""
|
|
337
|
+
device_path = f'/dev/{device_name}'
|
|
338
|
+
try: # Check if the device file exists and is writable
|
|
339
|
+
return os.access(device_path, os.W_OK)
|
|
340
|
+
except FileNotFoundError:
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
ok_nss = {}
|
|
344
|
+
for name, ns in nss.items():
|
|
345
|
+
if ns.major in self.disk_majors or whitelisted(name):
|
|
346
|
+
ok_nss[name] = ns
|
|
347
|
+
continue
|
|
348
|
+
if blacklisted(name):
|
|
349
|
+
continue
|
|
350
|
+
if writable(name):
|
|
351
|
+
if self.DB:
|
|
352
|
+
print(r'DB:include {repr(name)} [not white/black but writable]')
|
|
353
|
+
ok_nss[name] = ns
|
|
354
|
+
continue
|
|
355
|
+
if self.DB:
|
|
356
|
+
print(r'DB:exclude {repr(name)} [not white/black but unwritable]')
|
|
357
|
+
return ok_nss
|
|
358
|
+
|
|
359
|
+
def compute_field_widths(self, nss):
|
|
360
|
+
""" TBD """
|
|
361
|
+
|
|
362
|
+
wids = self.wids = SimpleNamespace(state=5, name=4, human=6, fstype=4, label=5)
|
|
363
|
+
for ns in nss.values():
|
|
364
|
+
wids.state = max(wids.state, len(ns.state))
|
|
365
|
+
# wids.fsuse = max(wids.fsuse, len(ns.fsuse))
|
|
366
|
+
wids.name = max(wids.name, len(ns.name)+2)
|
|
367
|
+
if ns.label is None:
|
|
368
|
+
pass
|
|
369
|
+
wids.label = max(wids.label, len(ns.label))
|
|
370
|
+
wids.fstype = max(wids.fstype, len(ns.fstype))
|
|
371
|
+
self.head_str = self.get_head_str()
|
|
372
|
+
if self.DB:
|
|
373
|
+
print('\n\nDB: --->>> after compute_field_widths():')
|
|
374
|
+
print(f'self.wids={vars(wids)}')
|
|
375
|
+
|
|
376
|
+
def get_head_str(self):
|
|
377
|
+
""" TBD """
|
|
378
|
+
sep = ' '
|
|
379
|
+
wids = self.wids
|
|
380
|
+
emit = f'{"STATE":_^{wids.state}}'
|
|
381
|
+
emit += f'{sep}{"NAME":_^{wids.name}}'
|
|
382
|
+
# emit += f'{sep}{"USE%":_^{wids.fsuse}}'
|
|
383
|
+
emit += f'{sep}{"SIZE":_^{wids.human}}'
|
|
384
|
+
emit += f'{sep}{"TYPE":_^{wids.fstype}}'
|
|
385
|
+
emit += f'{sep}{"LABEL":_^{wids.label}}'
|
|
386
|
+
emit += f'{sep}MOUNTS'
|
|
387
|
+
return emit
|
|
388
|
+
|
|
389
|
+
def part_str(self, partition):
|
|
390
|
+
""" Convert partition to human value. """
|
|
391
|
+
def print_str_or_dashes(name, width, chrs=' -'):
|
|
392
|
+
if not name.strip(): # Create a string of '─' characters of the specified width
|
|
393
|
+
result = f'{chrs}' * (width//2)
|
|
394
|
+
result += ' ' * (width%2)
|
|
395
|
+
else: # Format the name to be right-aligned within the specified width
|
|
396
|
+
result = f'{name:>{width}}'
|
|
397
|
+
return result
|
|
398
|
+
|
|
399
|
+
sep = ' '
|
|
400
|
+
ns = partition # shorthand
|
|
401
|
+
wids = self.wids
|
|
402
|
+
emit = f'{ns.state:^{wids.state}}'
|
|
403
|
+
# name_str = ('' if ns.parent is None else '⮞ ') + ns.name
|
|
404
|
+
#name_str = ('● ' if ns.parent is None else ' ') + ns.name
|
|
405
|
+
name_str = ('■ ' if ns.parent is None else ' ') + ns.name
|
|
406
|
+
# if ns.parent is None and 1 + len(name_str) <= wids.name:
|
|
407
|
+
# name_str += ' ' * (wids.name - len(name_str) - 1) + '■'
|
|
408
|
+
|
|
409
|
+
emit += f'{sep}{name_str:<{wids.name}}'
|
|
410
|
+
# emit += f'{sep}{ns.fsuse:^{wids.fsuse}}'
|
|
411
|
+
emit += f'{sep}{human(ns.size_bytes):>{wids.human}}'
|
|
412
|
+
emit += sep + print_str_or_dashes(ns.fstype, wids.fstype)
|
|
413
|
+
# emit += f'{sep}{ns.fstype:>{wids.fstype}}'
|
|
414
|
+
if ns.parent is None:
|
|
415
|
+
emit += sep + '■' + '─'*(wids.label-2) + '■'
|
|
416
|
+
else:
|
|
417
|
+
emit += sep + print_str_or_dashes(ns.label, wids.label, chrs=' -')
|
|
418
|
+
# emit += f' {ns.label:>{wids.label}}'
|
|
419
|
+
emit += f'{sep}{",".join(ns.mounts)}'
|
|
420
|
+
return emit
|
|
421
|
+
|
|
422
|
+
def merge_dev_infos(self, nss, prev_nss=None):
|
|
423
|
+
""" Merge old DevInfos into new DevInfos """
|
|
424
|
+
if not prev_nss:
|
|
425
|
+
return nss
|
|
426
|
+
for name, prev_ns in prev_nss.items():
|
|
427
|
+
# merge old jobs forward
|
|
428
|
+
new_ns = nss.get(name, None)
|
|
429
|
+
if new_ns:
|
|
430
|
+
if prev_ns.job:
|
|
431
|
+
new_ns.job = prev_ns.job
|
|
432
|
+
new_ns.state = new_ns.dflt = prev_ns.dflt
|
|
433
|
+
if prev_ns.state == 'Lock':
|
|
434
|
+
pass
|
|
435
|
+
if prev_ns.state not in ('Busy', 'Unlk'): # re-infer these
|
|
436
|
+
new_ns.state = prev_ns.state
|
|
437
|
+
elif prev_ns.job:
|
|
438
|
+
# unplugged device with job..
|
|
439
|
+
nss[name] = prev_ns # carry forward
|
|
440
|
+
prev_ns.job.do_abort = True
|
|
441
|
+
for name, prev_ns in nss.items():
|
|
442
|
+
if name not in prev_nss:
|
|
443
|
+
prev_ns.state = '^'
|
|
444
|
+
return nss
|
|
445
|
+
|
|
446
|
+
def assemble_partitions(self, prev_nss=None):
|
|
447
|
+
""" TBD """
|
|
448
|
+
nss = self.parse_lsblk(dflt='^' if prev_nss else '-')
|
|
449
|
+
|
|
450
|
+
nss = self.get_disk_partitions(nss)
|
|
451
|
+
|
|
452
|
+
nss = self.merge_dev_infos(nss, prev_nss)
|
|
453
|
+
|
|
454
|
+
self.set_all_states(nss) # set inferred states
|
|
455
|
+
|
|
456
|
+
self.compute_field_widths(nss)
|
|
457
|
+
|
|
458
|
+
if self.DB:
|
|
459
|
+
print('\n\nDB: --->>> after assemble_partitions():')
|
|
460
|
+
for name, ns in nss.items():
|
|
461
|
+
print(f'DB: {name}: {vars(ns)}')
|
|
462
|
+
self.partitions = nss
|
|
463
|
+
return nss
|
|
464
|
+
|
|
465
|
+
class DiskWipe:
|
|
466
|
+
"""" TBD """
|
|
467
|
+
singleton = None
|
|
468
|
+
def __init__(self, opts=None):
|
|
469
|
+
DiskWipe.singleton = self
|
|
470
|
+
self.opts = opts if opts else SimpleNamespace(debug=0, dry_run=False)
|
|
471
|
+
self.DB = bool(self.opts.debug)
|
|
472
|
+
self.mounts_lines = None
|
|
473
|
+
self.partitions = {} # a dict of namespaces keyed by name
|
|
474
|
+
self.visibles = [] # visible partitions given the filter
|
|
475
|
+
self.wids = None
|
|
476
|
+
self.job_cnt = 0
|
|
477
|
+
self.exit_when_no_jobs = False
|
|
478
|
+
|
|
479
|
+
self.prev_filter = '' # string
|
|
480
|
+
self.filter = None # compiled pattern
|
|
481
|
+
self.pick_is_running = False
|
|
482
|
+
self.pick_name = '' # device name of current pick line
|
|
483
|
+
self.pick_actions = {} # key, tag
|
|
484
|
+
self.dev_info = None
|
|
485
|
+
|
|
486
|
+
# EXPAND
|
|
487
|
+
self.win, self.spin = None, None
|
|
488
|
+
|
|
489
|
+
self.check_preqreqs()
|
|
490
|
+
|
|
491
|
+
@staticmethod
|
|
492
|
+
def check_preqreqs():
|
|
493
|
+
""" Check that needed programs are installed. """
|
|
494
|
+
ok = True
|
|
495
|
+
for prog in 'lsblk'.split():
|
|
496
|
+
if shutil.which(prog) is None:
|
|
497
|
+
ok = False
|
|
498
|
+
print(f'ERROR: cannot find {prog!r} on $PATH')
|
|
499
|
+
if not ok:
|
|
500
|
+
sys.exit(1)
|
|
501
|
+
|
|
502
|
+
def test_state(self, ns, to=None):
|
|
503
|
+
""" Test if OK to set state of partition """
|
|
504
|
+
return self.dev_info.set_one_state(self.partitions, ns, test_to=to)
|
|
505
|
+
|
|
506
|
+
def set_state(self, ns, to=None):
|
|
507
|
+
""" Set state of partition """
|
|
508
|
+
return self.dev_info.set_one_state(self.partitions, ns, to=to)
|
|
509
|
+
|
|
510
|
+
@staticmethod
|
|
511
|
+
def mod_pick(line):
|
|
512
|
+
""" Callback to modify the "pick line" being highlighted;
|
|
513
|
+
We use it to alter the state
|
|
514
|
+
"""
|
|
515
|
+
this = DiskWipe.singleton
|
|
516
|
+
this.pick_name, this.pick_actions = this.get_actions(line)
|
|
517
|
+
header = this.get_keys_line()
|
|
518
|
+
# ASSUME line ends in /....
|
|
519
|
+
parts = header.split('/', maxsplit=1)
|
|
520
|
+
wds = parts[0].split()
|
|
521
|
+
this.win.head.pad.move(0, 0)
|
|
522
|
+
for wd in wds:
|
|
523
|
+
if wd[0]in ('<', '|', '❚'):
|
|
524
|
+
this.win.add_header(wd + ' ', resume=True)
|
|
525
|
+
continue
|
|
526
|
+
if wd:
|
|
527
|
+
this.win.add_header(wd[0], attr=cs.A_BOLD|cs.A_UNDERLINE, resume=True)
|
|
528
|
+
if wd[1:]:
|
|
529
|
+
this.win.add_header(wd[1:] + ' ', resume=True)
|
|
530
|
+
|
|
531
|
+
this.win.add_header('/', attr=cs.A_BOLD+cs.A_UNDERLINE, resume=True)
|
|
532
|
+
if len(parts) > 1 and parts[1]:
|
|
533
|
+
this.win.add_header(f'{parts[1]}', resume=True)
|
|
534
|
+
_, col = this.win.head.pad.getyx()
|
|
535
|
+
pad = ' ' * (this.win.get_pad_width()-col)
|
|
536
|
+
this.win.add_header(pad, resume=True)
|
|
537
|
+
return line
|
|
538
|
+
def do_key(self, key):
|
|
539
|
+
""" TBD """
|
|
540
|
+
def stop_if_idle(part):
|
|
541
|
+
if part.state[-1] == '%':
|
|
542
|
+
if part.job and not part.job.done:
|
|
543
|
+
part.job.do_abort = True
|
|
544
|
+
return 1 if part.job else 0
|
|
545
|
+
|
|
546
|
+
def stop_all():
|
|
547
|
+
rv = 0
|
|
548
|
+
for part in self.partitions.values():
|
|
549
|
+
rv += stop_if_idle(part)
|
|
550
|
+
return rv # number jobs running
|
|
551
|
+
|
|
552
|
+
def exit_if_no_jobs():
|
|
553
|
+
if stop_all() == 0:
|
|
554
|
+
self.win.stop_curses()
|
|
555
|
+
os.system('clear; stty sane')
|
|
556
|
+
sys.exit(0)
|
|
557
|
+
return True # continue running
|
|
558
|
+
|
|
559
|
+
if self.exit_when_no_jobs:
|
|
560
|
+
return exit_if_no_jobs()
|
|
561
|
+
|
|
562
|
+
if not key:
|
|
563
|
+
return True
|
|
564
|
+
if key in (cs.KEY_ENTER, 10): # Handle ENTER
|
|
565
|
+
if self.opts.help_mode:
|
|
566
|
+
self.opts.help_mode = False
|
|
567
|
+
return None
|
|
568
|
+
|
|
569
|
+
if key in self.spin.keys:
|
|
570
|
+
value = self.spin.do_key(key, self.win)
|
|
571
|
+
return value
|
|
572
|
+
|
|
573
|
+
if key == 27: # ESCAPE
|
|
574
|
+
self.prev_filter = ''
|
|
575
|
+
self.filter = None
|
|
576
|
+
self.win.pick_pos = 0
|
|
577
|
+
return None
|
|
578
|
+
|
|
579
|
+
if key in (ord('q'), ord('x')):
|
|
580
|
+
self.exit_when_no_jobs = True
|
|
581
|
+
self.filter = re.compile('STOPPING', re.IGNORECASE)
|
|
582
|
+
self.prev_filter = 'STOPPING'
|
|
583
|
+
return exit_if_no_jobs()
|
|
584
|
+
|
|
585
|
+
if key == ord('w') and not self.pick_is_running:
|
|
586
|
+
part = self.partitions[self.pick_name]
|
|
587
|
+
if self.test_state(part, to='0%'):
|
|
588
|
+
ans = self.win.answer(f'Type "y" to wipe {repr(part.name)} : '
|
|
589
|
+
+ f' st={repr(part.state)} sz={human(part.size_bytes)}'
|
|
590
|
+
+ f' ty={part.fstype} label={part.label}'
|
|
591
|
+
)
|
|
592
|
+
if ans.strip().lower().startswith('y'):
|
|
593
|
+
part.job = WipeJob.start_job(f'/dev/{part.name}',
|
|
594
|
+
part.size_bytes, opts=self.opts)
|
|
595
|
+
self.job_cnt += 1
|
|
596
|
+
self.set_state(part, to='0%')
|
|
597
|
+
return None
|
|
598
|
+
|
|
599
|
+
if key == ord('s') and self.pick_is_running:
|
|
600
|
+
part = self.partitions[self.pick_name]
|
|
601
|
+
stop_if_idle(part)
|
|
602
|
+
return None
|
|
603
|
+
|
|
604
|
+
if key == ord('S'):
|
|
605
|
+
for part in self.partitions.values():
|
|
606
|
+
stop_if_idle(part)
|
|
607
|
+
return None
|
|
608
|
+
|
|
609
|
+
if key == ord('l'):
|
|
610
|
+
part = self.partitions[self.pick_name]
|
|
611
|
+
self.set_state(part, 'Unlk' if part.state == 'Lock' else 'Lock')
|
|
612
|
+
|
|
613
|
+
if key == ord('/'):
|
|
614
|
+
# pylint: disable=protected-access
|
|
615
|
+
start_filter = self.prev_filter
|
|
616
|
+
|
|
617
|
+
prefix = ''
|
|
618
|
+
while True:
|
|
619
|
+
pattern = self.win.answer(f'{prefix}Enter filter regex:', seed=self.prev_filter)
|
|
620
|
+
self.prev_filter = pattern
|
|
621
|
+
|
|
622
|
+
pattern.strip()
|
|
623
|
+
if not pattern:
|
|
624
|
+
self.filter = None
|
|
625
|
+
break
|
|
626
|
+
|
|
627
|
+
try:
|
|
628
|
+
self.filter = re.compile(pattern, re.IGNORECASE)
|
|
629
|
+
break
|
|
630
|
+
except Exception:
|
|
631
|
+
prefix = 'Bad regex: '
|
|
632
|
+
|
|
633
|
+
if start_filter != self.prev_filter:
|
|
634
|
+
# when filter changes, move to top
|
|
635
|
+
self.win.pick_pos = 0
|
|
636
|
+
|
|
637
|
+
return None
|
|
638
|
+
return None
|
|
639
|
+
|
|
640
|
+
def get_keys_line(self):
|
|
641
|
+
""" TBD """
|
|
642
|
+
# KEYS
|
|
643
|
+
line = ''
|
|
644
|
+
for key, verb in self.pick_actions.items():
|
|
645
|
+
if key[0] == verb[0]:
|
|
646
|
+
line += f' {verb}'
|
|
647
|
+
else:
|
|
648
|
+
line += f' {key}:{verb}'
|
|
649
|
+
line += ' ❚'
|
|
650
|
+
line += ' Stop' if self.job_cnt > 0 else ''
|
|
651
|
+
line += f' quit ?:help /{self.prev_filter} Mode='
|
|
652
|
+
line += f'{"Random" if self.opts.random else "Zeros"}'
|
|
653
|
+
# for action in self.actions:
|
|
654
|
+
# line += f' {action[0]}:{action}'
|
|
655
|
+
return line[1:]
|
|
656
|
+
|
|
657
|
+
def get_actions(self, part):
|
|
658
|
+
""" Determine the type of the current line and available commands."""
|
|
659
|
+
# KEYS
|
|
660
|
+
name, actions = '', {}
|
|
661
|
+
lines = self.win.body.texts
|
|
662
|
+
if 0 <= self.win.pick_pos < len(lines):
|
|
663
|
+
# line = lines[self.win.pick_pos]
|
|
664
|
+
part = self.visibles[self.win.pick_pos]
|
|
665
|
+
name = part.name
|
|
666
|
+
self.pick_is_running = bool(part.job)
|
|
667
|
+
# EXPAND
|
|
668
|
+
if self.test_state(part, to='STOP'):
|
|
669
|
+
actions['s'] = 'stop'
|
|
670
|
+
elif self.test_state(part, to='0%'):
|
|
671
|
+
actions['w'] = 'wipe'
|
|
672
|
+
if self.test_state(part, to='Lock'):
|
|
673
|
+
actions['l'] = 'lock'
|
|
674
|
+
if self.test_state(part, to='Unlk'):
|
|
675
|
+
actions['l'] = 'unlk'
|
|
676
|
+
return name, actions
|
|
677
|
+
|
|
678
|
+
def main_loop(self):
|
|
679
|
+
""" TBD """
|
|
680
|
+
|
|
681
|
+
spin = self.spin = OptionSpinner()
|
|
682
|
+
spin.default_obj = self.opts
|
|
683
|
+
spin.add_key('help_mode', '? - toggle help screen', vals=[False, True])
|
|
684
|
+
spin.add_key('random', 'r - toggle whether random or zeros', vals=[True, False])
|
|
685
|
+
# KEYS
|
|
686
|
+
other = 'wsl/Sqx'
|
|
687
|
+
other_keys = set(ord(x) for x in other)
|
|
688
|
+
other_keys.add(cs.KEY_ENTER)
|
|
689
|
+
other_keys.add(10) # another form of ENTER
|
|
690
|
+
other_keys.add(27) # ESCAPE
|
|
691
|
+
|
|
692
|
+
self.win = Window(head_line=True, body_rows=200, head_rows=4,
|
|
693
|
+
keys=spin.keys ^ other_keys, mod_pick=self.mod_pick)
|
|
694
|
+
self.opts.name = "[hit 'n' to enter name]"
|
|
695
|
+
check_devices_mono = time.monotonic()
|
|
696
|
+
while True:
|
|
697
|
+
if self.opts.help_mode:
|
|
698
|
+
self.win.set_pick_mode(False)
|
|
699
|
+
self.spin.show_help_nav_keys(self.win)
|
|
700
|
+
self.spin.show_help_body(self.win)
|
|
701
|
+
# KEYS
|
|
702
|
+
lines = [
|
|
703
|
+
'CONTEXT SENSITIVE:',
|
|
704
|
+
' w - wipe device',
|
|
705
|
+
' s - stop wipe device',
|
|
706
|
+
' l - lock/unlock disk',
|
|
707
|
+
' S - stop ALL wipes in progress',
|
|
708
|
+
'GENERALLY AVAILABLE:',
|
|
709
|
+
' q or x - quit program (CTL-C disabled)',
|
|
710
|
+
' / - filter devices by (anchored) regex',
|
|
711
|
+
' ESC = clear filter and jump to top',
|
|
712
|
+
' ENTER = return from help',
|
|
713
|
+
|
|
714
|
+
]
|
|
715
|
+
for line in lines:
|
|
716
|
+
self.win.put_body(line)
|
|
717
|
+
else:
|
|
718
|
+
def wanted(name):
|
|
719
|
+
return not self.filter or self.filter.search(name)
|
|
720
|
+
# self.win.set_pick_mode(self.opts.pick_mode, self.opts.pick_size)
|
|
721
|
+
self.win.set_pick_mode(True)
|
|
722
|
+
|
|
723
|
+
self.visibles = []
|
|
724
|
+
for name, partition in self.partitions.items():
|
|
725
|
+
partition.line = None
|
|
726
|
+
if partition.job:
|
|
727
|
+
if partition.job.done:
|
|
728
|
+
partition.job.thread.join()
|
|
729
|
+
to='s' if partition.job.do_abort else 'W'
|
|
730
|
+
self.set_state(partition, to=to)
|
|
731
|
+
partition.dflt = to
|
|
732
|
+
partition.job = None
|
|
733
|
+
partition.mounts = []
|
|
734
|
+
self.job_cnt -= 1
|
|
735
|
+
if partition.job:
|
|
736
|
+
elapsed, pct, rate, until = partition.job.get_status()
|
|
737
|
+
partition.state = pct
|
|
738
|
+
partition.mounts = [f'{elapsed} {rate} REM:{until}']
|
|
739
|
+
|
|
740
|
+
if partition.parent and self.partitions[partition.parent].state == 'Lock':
|
|
741
|
+
continue
|
|
742
|
+
|
|
743
|
+
if wanted(name) or partition.job:
|
|
744
|
+
partition.line = self.dev_info.part_str(partition)
|
|
745
|
+
self.win.add_body(partition.line)
|
|
746
|
+
self.visibles.append(partition)
|
|
747
|
+
|
|
748
|
+
self.win.add_header(self.get_keys_line(), attr=cs.A_BOLD)
|
|
749
|
+
|
|
750
|
+
self.win.add_header(self.dev_info.head_str)
|
|
751
|
+
_, col = self.win.head.pad.getyx()
|
|
752
|
+
pad = ' ' * (self.win.get_pad_width()-col)
|
|
753
|
+
self.win.add_header(pad, resume=True)
|
|
754
|
+
self.win.render()
|
|
755
|
+
|
|
756
|
+
seconds = 3.0
|
|
757
|
+
_ = self.do_key(self.win.prompt(seconds=seconds))
|
|
758
|
+
|
|
759
|
+
if time.monotonic() - check_devices_mono > (seconds * 0.95):
|
|
760
|
+
info = DeviceInfo(opts=self.opts)
|
|
761
|
+
self.partitions = info.assemble_partitions(self.partitions)
|
|
762
|
+
self.dev_info = info
|
|
763
|
+
check_devices_mono = time.monotonic()
|
|
764
|
+
|
|
765
|
+
self.win.clear()
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def rerun_module_as_root(module_name):
|
|
769
|
+
""" rerun using the module name """
|
|
770
|
+
if os.geteuid() != 0: # Re-run the script with sudo
|
|
771
|
+
os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
772
|
+
vp = ['sudo', sys.executable, '-m', module_name] + sys.argv[1:]
|
|
773
|
+
os.execvp('sudo', vp)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def main():
|
|
777
|
+
"""Main loop"""
|
|
778
|
+
import argparse
|
|
779
|
+
parser = argparse.ArgumentParser()
|
|
780
|
+
parser.add_argument('-n', '--dry-run', action='store_true',
|
|
781
|
+
help='just pretend to zap devices')
|
|
782
|
+
parser.add_argument('-D', '--debug', action='count', default=0,
|
|
783
|
+
help='debug mode (the more Ds, the higher the debug level)')
|
|
784
|
+
opts = parser.parse_args()
|
|
785
|
+
|
|
786
|
+
try:
|
|
787
|
+
if os.geteuid() != 0:
|
|
788
|
+
# Re-run the script with sudo needed and opted
|
|
789
|
+
rerun_module_as_root('dwipe.main')
|
|
790
|
+
|
|
791
|
+
dwipe = DiskWipe(opts=opts)
|
|
792
|
+
dwipe.dev_info = info = DeviceInfo(opts=opts)
|
|
793
|
+
dwipe.partitions = info.assemble_partitions()
|
|
794
|
+
if dwipe.DB:
|
|
795
|
+
sys.exit(1)
|
|
796
|
+
|
|
797
|
+
dwipe.main_loop()
|
|
798
|
+
except Exception as exce:
|
|
799
|
+
if dwipe and dwipe.win:
|
|
800
|
+
dwipe.win.stop_curses()
|
|
801
|
+
print("exception:", str(exce))
|
|
802
|
+
print(traceback.format_exc())
|
|
803
|
+
sys.exit(15)
|
|
804
|
+
|
|
805
|
+
if __name__ == "__main__":
|
|
806
|
+
main()
|