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