dwipe 2.0.1__py3-none-any.whl → 3.0.0__py3-none-any.whl

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