dwipe 2.0.1__py3-none-any.whl → 2.0.2__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
@@ -5,11 +5,25 @@ import os
5
5
  import sys
6
6
  import datetime
7
7
  from pathlib import Path
8
+ from .StructuredLogger import StructuredLogger
8
9
 
9
10
 
10
11
  class Utils:
11
12
  """Utility functions encapsulated as a family"""
12
13
 
14
+ # Singleton logger instance
15
+ _logger = None
16
+
17
+ @staticmethod
18
+ def get_logger():
19
+ """Get or create the singleton StructuredLogger instance"""
20
+ if Utils._logger is None:
21
+ Utils._logger = StructuredLogger(
22
+ app_name='dwipe',
23
+ log_dir=Utils.get_config_dir()
24
+ )
25
+ return Utils._logger
26
+
13
27
  @staticmethod
14
28
  def human(number):
15
29
  """Return a concise number description."""
@@ -129,6 +143,100 @@ class Utils:
129
143
  except Exception:
130
144
  pass # Don't fail if log trimming fails
131
145
 
146
+ @staticmethod
147
+ def get_device_dict(partitions, partition):
148
+ """Extract device information from partition namespace as dict
149
+
150
+ Args:
151
+ partition: Partition namespace object with device attributes
152
+
153
+ Returns:
154
+ dict: Device information for structured logging
155
+ """
156
+ if partition.name in partitions:
157
+ disk = partitions[partition.name]
158
+ while disk.parent:
159
+ disk = partitions[disk.parent]
160
+ else:
161
+ disk = partition
162
+ device_dict = {
163
+ "name": partition.name,
164
+ "path": f"/dev/{partition.name}",
165
+ "size": Utils.human(partition.size_bytes),
166
+ }
167
+ device_dict["uuid"] = partition.uuid
168
+ if partition.type:
169
+ device_dict["type"] = partition.type
170
+ if partition.fstype:
171
+ device_dict["fstype"] = partition.fstype
172
+ if partition.label:
173
+ device_dict["label"] = partition.label
174
+
175
+ device_dict["model"] = disk.model
176
+ device_dict["serial"] = disk.serial
177
+ device_dict["port"] = disk.port
178
+
179
+ return device_dict
180
+
181
+ @staticmethod
182
+ def log_wipe_structured(partitions, partition, job, mode=None):
183
+ """Log a wipe or verify operation using structured logging
184
+
185
+ Args:
186
+ partition: Partition namespace object with device info
187
+ job: WipeJob object with job statistics
188
+ mode: Optional mode override (defaults to job.opts.wipe_mode)
189
+ """
190
+ logger = Utils.get_logger()
191
+
192
+ # Determine log level based on result
193
+ is_verify_only = getattr(job, 'is_verify_only', False)
194
+ is_stopped = job.do_abort
195
+
196
+ if is_verify_only:
197
+ level = "VERIFY_STOPPED" if is_stopped else "VERIFY_COMPLETE"
198
+ else:
199
+ level = "WIPE_STOPPED" if is_stopped else "WIPE_COMPLETE"
200
+
201
+ # Get the three sections
202
+ plan = job.get_plan_dict(mode)
203
+ device = Utils.get_device_dict(partitions, partition)
204
+ summary = job.get_summary_dict()
205
+
206
+ # Create summary message
207
+ result_str = summary['result']
208
+ size_str = device['size']
209
+ time_str = summary['total_elapsed']
210
+ # Get rate from first step (wipe step)
211
+ rate_str = summary['steps'][0]['rate'] if summary['steps'] else 'N/A'
212
+
213
+ # Build base message
214
+ operation = plan['operation'].capitalize()
215
+ message = f"{operation} {result_str}: {device['name']} {size_str}"
216
+
217
+ # Add percentage if stopped
218
+ if result_str == 'stopped' and summary.get('pct_complete', 0) > 0:
219
+ message += f" ({summary['pct_complete']:.0f}%)"
220
+
221
+ # Add timing and rate
222
+ message += f" in {time_str} @ {rate_str}"
223
+
224
+ # Add error reason if present
225
+ abort_reason = summary.get('abort_reason')
226
+ if abort_reason:
227
+ message += f" [Error: {abort_reason}]"
228
+
229
+ # Log the structured event
230
+ logger.put(
231
+ level,
232
+ message,
233
+ data={
234
+ "plan": plan,
235
+ "device": device,
236
+ "summary": summary
237
+ }
238
+ )
239
+
132
240
  @staticmethod
133
241
  def log_wipe(device_name, size_bytes, mode, result, elapsed_time=None, uuid=None, label=None, fstype=None, pct=None, verify_result=None):
134
242
  """Log a wipe or verify operation to ~/.config/dwipe/log.txt
dwipe/VerifyTask.py ADDED
@@ -0,0 +1,410 @@
1
+ """
2
+ VerifyTask - Abstract base class and implementations for verification operations
3
+
4
+ Includes:
5
+ - VerifyTask: Abstract base class with verification logic and statistical analysis
6
+ - VerifyZeroTask: Concrete class for verifying zeros (fast memcmp)
7
+ - VerifyRandTask: Concrete class for verifying random data (statistical)
8
+ """
9
+ # pylint: disable=broad-exception-raised,broad-exception-caught
10
+ import os
11
+ import time
12
+ import random
13
+ import traceback
14
+ from types import SimpleNamespace
15
+
16
+ from .WipeTask import WipeTask
17
+ from .Utils import Utils
18
+
19
+
20
+ class VerifyTask(WipeTask):
21
+ """Abstract base class for verify operations (VerifyZeroTask, VerifyRandTask)
22
+
23
+ Implements verification logic with:
24
+ - Section-by-section analysis of disk content
25
+ - Fast-fail for zero verification (memcmp)
26
+ - Statistical analysis for random pattern verification
27
+ - Progress tracking
28
+
29
+ Subclasses must set:
30
+ - expected_pattern: "zeroed" or "random"
31
+ - fast_fail: True for zero (fast memcmp), False for random (statistical)
32
+ """
33
+
34
+ def __init__(self, device_path, total_size, opts=None, verify_pct=2, expected_pattern=None):
35
+ """Initialize verify task
36
+
37
+ Args:
38
+ device_path: Path to device (e.g., '/dev/sda1')
39
+ total_size: Total size in bytes
40
+ opts: Options namespace
41
+ verify_pct: Percentage of disk to verify (e.g., 2 for 2%)
42
+ expected_pattern: "zeroed", "random", or None (auto-detect)
43
+ """
44
+ super().__init__(device_path, total_size, opts)
45
+
46
+ # Verify-specific attributes
47
+ self.verify_pct = verify_pct
48
+ self.expected_pattern = expected_pattern
49
+ self.verify_result = None # "zeroed", "random", "not-wiped", "mixed", "error"
50
+ self.section_results = [] # Section-by-section results
51
+ self.verify_progress = 0 # Bytes verified (for total_written tracking)
52
+
53
+ # Fast-fail flag (set by subclasses)
54
+ self.fast_fail = False
55
+
56
+ def run_task(self):
57
+ """Execute verification operation (blocking, runs in thread)"""
58
+ try:
59
+ if self.verify_pct == 0:
60
+ self.verify_result = "skipped"
61
+ self.done = True
62
+ return
63
+
64
+ # Fast-fail for zeros (VerifyZeroTask)
65
+ fast_fail_zeros = self.fast_fail and self.expected_pattern == "zeroed"
66
+
67
+ # For unmarked disks: track if ALL bytes are zero
68
+ all_zeros = (self.expected_pattern is None)
69
+
70
+ # Open with regular buffered I/O
71
+ fd = os.open(self.device_path, os.O_RDONLY)
72
+
73
+ try:
74
+ read_chunk_size = 64 * 1024 # 64KB chunks
75
+ SAMPLE_STEP = 23 # Sample every 23rd byte (~4% of data) - prime for even distribution
76
+
77
+ # Skip marker area
78
+ marker_skip = WipeTask.BUFFER_SIZE
79
+ usable_size = self.total_size - marker_skip
80
+
81
+ # Divide disk into 100 sections for sampling
82
+ num_sections = 100
83
+ section_size = usable_size // num_sections
84
+
85
+ # Pre-allocated zero pattern for fast comparison
86
+ ZERO_PATTERN_64K = b'\x00' * (64 * 1024)
87
+
88
+ # Track if any section failed
89
+ overall_failed = False
90
+ failure_reason = ""
91
+
92
+ for section_idx in range(num_sections):
93
+ if self.do_abort or overall_failed:
94
+ break
95
+
96
+ # Reset analysis for THIS SECTION
97
+ section_byte_counts = [0] * 256
98
+ section_samples = 0
99
+ section_found_nonzero = False
100
+
101
+ # Calculate bytes to verify in this section
102
+ bytes_in_section = min(section_size, usable_size - section_idx * section_size)
103
+ bytes_to_verify = int(bytes_in_section * self.verify_pct / 100)
104
+
105
+ if bytes_to_verify == 0:
106
+ self.section_results.append((section_idx, "skipped", {}))
107
+ continue
108
+
109
+ # Random offset within section
110
+ if bytes_to_verify < bytes_in_section:
111
+ offset_in_section = random.randint(0, bytes_in_section - bytes_to_verify)
112
+ else:
113
+ offset_in_section = 0
114
+
115
+ read_pos = marker_skip + (section_idx * section_size) + offset_in_section
116
+ verified_in_section = 0
117
+
118
+ # Seek to position in this section
119
+ os.lseek(fd, read_pos, os.SEEK_SET)
120
+
121
+ # Read and analyze THIS SECTION
122
+ while verified_in_section < bytes_to_verify:
123
+ if self.do_abort:
124
+ break
125
+
126
+ chunk_size = min(read_chunk_size, bytes_to_verify - verified_in_section)
127
+
128
+ data = os.read(fd, chunk_size)
129
+ if not data:
130
+ break
131
+
132
+ # --------------------------------------------------
133
+ # SECTION ANALYSIS
134
+ # --------------------------------------------------
135
+
136
+ # FAST zero check for zeroed pattern
137
+ if fast_fail_zeros:
138
+ # Ultra-fast: compare against pre-allocated zero pattern
139
+ if memoryview(data) != ZERO_PATTERN_64K[:len(data)]:
140
+ failed_offset = read_pos + verified_in_section
141
+ overall_failed = True
142
+ failure_reason = f"non-zero at {Utils.human(failed_offset)}"
143
+ break
144
+
145
+ # FAST check for unmarked disks (looking for all zeros)
146
+ if all_zeros and not section_found_nonzero:
147
+ # Fast check: use bytes.count() which is C-optimized
148
+ if data.count(0) != len(data):
149
+ section_found_nonzero = True
150
+
151
+ # RANDOM pattern analysis (always collect data for analysis)
152
+ # Use memoryview for fast slicing
153
+ mv = memoryview(data)
154
+ data_len = len(data)
155
+
156
+ # Sample every SAMPLE_STEP-th byte
157
+ for i in range(0, data_len, SAMPLE_STEP):
158
+ section_byte_counts[mv[i]] += 1
159
+ section_samples += 1
160
+
161
+ # --------------------------------------------------
162
+ # END SECTION ANALYSIS
163
+ # --------------------------------------------------
164
+
165
+ verified_in_section += len(data)
166
+ self.verify_progress += len(data) # Track actual bytes read for progress
167
+ self.total_written = self.verify_progress # Update for get_status()
168
+
169
+ # After reading section, analyze it
170
+ if overall_failed:
171
+ break
172
+
173
+ # Determine section result
174
+ if fast_fail_zeros:
175
+ # Already passed zero check if we got here
176
+ section_result = "zeroed"
177
+ section_stats = {}
178
+
179
+ elif all_zeros:
180
+ if not section_found_nonzero:
181
+ section_result = "zeroed"
182
+ section_stats = {}
183
+ else:
184
+ # Need to check if it's random
185
+ section_result, section_stats = self._analyze_section_randomness(
186
+ section_byte_counts, section_samples
187
+ )
188
+
189
+ else: # Expected random
190
+ section_result, section_stats = self._analyze_section_randomness(
191
+ section_byte_counts, section_samples
192
+ )
193
+
194
+ # Store section result
195
+ self.section_results.append((section_idx, section_result, section_stats))
196
+
197
+ # Check if section failed
198
+ if (self.expected_pattern == "random" and section_result != "random") or \
199
+ (self.expected_pattern == "zeroed" and section_result != "zeroed") or \
200
+ (self.expected_pattern is None and section_result == "not-wiped"):
201
+
202
+ overall_failed = True
203
+ failure_reason = f"section {section_idx}: {section_result}"
204
+ break
205
+
206
+ finally:
207
+ # Close file descriptor
208
+ if fd is not None:
209
+ os.close(fd)
210
+
211
+ # Determine overall result
212
+ if overall_failed:
213
+ if self.expected_pattern == "zeroed":
214
+ self.verify_result = f"not-wiped ({failure_reason})"
215
+ elif self.expected_pattern == "random":
216
+ self.verify_result = f"not-wiped ({failure_reason})"
217
+ else: # unmarked
218
+ # Count section results
219
+ zeroed_sections = sum(1 for _, result, _ in self.section_results if result == "zeroed")
220
+ random_sections = sum(1 for _, result, _ in self.section_results if result == "random")
221
+ total_checked = len([r for _, r, _ in self.section_results if r != "skipped"])
222
+
223
+ if zeroed_sections == total_checked:
224
+ self.verify_result = "zeroed"
225
+ self.expected_pattern = "zeroed"
226
+ elif random_sections == total_checked:
227
+ self.verify_result = "random"
228
+ self.expected_pattern = "random"
229
+ else:
230
+ self.verify_result = f"mixed ({failure_reason})"
231
+ else:
232
+ # All sections passed
233
+ if self.expected_pattern == "zeroed":
234
+ self.verify_result = "zeroed"
235
+ elif self.expected_pattern == "random":
236
+ self.verify_result = "random"
237
+ else: # unmarked
238
+ # Determine from section consensus
239
+ zeroed_sections = sum(1 for _, result, _ in self.section_results if result == "zeroed")
240
+ random_sections = sum(1 for _, result, _ in self.section_results if result == "random")
241
+
242
+ if zeroed_sections > random_sections:
243
+ self.verify_result = "zeroed"
244
+ self.expected_pattern = "zeroed"
245
+ else:
246
+ self.verify_result = "random"
247
+ self.expected_pattern = "random"
248
+
249
+ self.done = True
250
+ except Exception:
251
+ self.exception = traceback.format_exc()
252
+ self.verify_result = "error"
253
+ self.done = True
254
+
255
+ def _analyze_section_randomness(self, byte_counts, total_samples):
256
+ """Analyze if a section appears random"""
257
+ if total_samples < 100:
258
+ return "insufficient-data", {"samples": total_samples}
259
+
260
+ # Calculate statistics
261
+ max_count = max(byte_counts)
262
+ max_freq = max_count / total_samples
263
+
264
+ # Count unique bytes seen
265
+ unique_bytes = sum(1 for count in byte_counts if count > 0)
266
+
267
+ # Count completely unused bytes
268
+ unused_bytes = sum(1 for count in byte_counts if count == 0)
269
+
270
+ # Calculate expected frequency and variance
271
+ expected = total_samples / 256
272
+ if expected > 0:
273
+ # Coefficient of variation (measure of dispersion)
274
+ variance = sum((count - expected) ** 2 for count in byte_counts) / 256
275
+ std_dev = variance ** 0.5
276
+ cv = std_dev / expected
277
+ else:
278
+ cv = float('inf')
279
+
280
+ # Decision logic for "random"
281
+ # Good random data should:
282
+ # 1. Use most byte values (>200 unique)
283
+ # 2. No single byte dominates (<2% frequency)
284
+ # 3. Relatively even distribution (CV < 2.0)
285
+ # 4. Not too many zeros (if it's supposed to be random, not zeroed)
286
+
287
+ is_random = (unique_bytes > 200 and # >78% of bytes used
288
+ max_freq < 0.02 and # No byte > 2%
289
+ cv < 2.0 and # Not too lumpy
290
+ byte_counts[0] / total_samples < 0.5) # Not mostly zeros
291
+
292
+ stats = {
293
+ "samples": total_samples,
294
+ "max_freq": max_freq,
295
+ "unique_bytes": unique_bytes,
296
+ "unused_bytes": unused_bytes,
297
+ "cv": cv,
298
+ "zero_freq": byte_counts[0] / total_samples if total_samples > 0 else 0
299
+ }
300
+
301
+ if is_random:
302
+ return "random", stats
303
+ else:
304
+ # Check if it's zeros
305
+ if byte_counts[0] / total_samples > 0.95:
306
+ return "zeroed", stats
307
+ else:
308
+ return "not-wiped", stats
309
+
310
+ def get_status(self):
311
+ """Get current progress status (thread-safe, called from main thread)
312
+
313
+ Returns verification percentage with 'v' prefix (e.g., "v45%")
314
+ """
315
+ mono = time.monotonic()
316
+ elapsed_time = mono - self.start_mono
317
+
318
+ # Calculate total bytes to verify (verify_pct% of total_size)
319
+ if self.verify_pct > 0:
320
+ total_to_verify = self.total_size * self.verify_pct / 100
321
+ else:
322
+ total_to_verify = self.total_size
323
+
324
+ # Calculate verification percentage (0-100)
325
+ pct = int((self.verify_progress / total_to_verify) * 100) if total_to_verify > 0 else 0
326
+ pct_str = f'v{pct}%'
327
+
328
+ if self.do_abort:
329
+ pct_str = 'STOP'
330
+
331
+ # Track verification progress for rate calculation
332
+ self.wr_hists.append(SimpleNamespace(mono=mono, written=self.verify_progress))
333
+ floor = mono - 30
334
+ while len(self.wr_hists) >= 3 and self.wr_hists[1].mono >= floor:
335
+ del self.wr_hists[0]
336
+
337
+ delta_mono = mono - self.wr_hists[0].mono
338
+ physical_rate = (self.verify_progress - self.wr_hists[0].written) / delta_mono if delta_mono > 1.0 else 0
339
+ # Scale rate to show "effective" verification rate (as if verifying 100% of disk)
340
+ effective_rate = physical_rate * (100 / self.verify_pct) if self.verify_pct > 0 else physical_rate
341
+ rate_str = f'{Utils.human(int(round(effective_rate, 0)))}/s'
342
+
343
+ if physical_rate > 0:
344
+ remaining = total_to_verify - self.verify_progress
345
+ when = int(round(remaining / physical_rate))
346
+ when_str = Utils.ago_str(when)
347
+ else:
348
+ when_str = '0'
349
+
350
+ return Utils.ago_str(int(round(elapsed_time))), pct_str, rate_str, when_str
351
+
352
+ def get_summary_dict(self):
353
+ """Get final summary for this verify task
354
+
355
+ Returns:
356
+ dict: Summary with step name, elapsed, rate, bytes checked, result
357
+ """
358
+ mono = time.monotonic()
359
+ elapsed = mono - self.start_mono
360
+ rate_bps = self.verify_progress / elapsed if elapsed > 0 else 0
361
+
362
+ # Determine mode from expected pattern
363
+ mode = "Rand" if self.expected_pattern == "random" else "Zero"
364
+
365
+ # Build verify label
366
+ verify_label = f"verify {mode}"
367
+ if self.verify_pct > 0 and self.verify_pct < 100:
368
+ verify_label += f" ({self.verify_pct}% sample)"
369
+
370
+ # Extract verify detail if present
371
+ verify_detail = None
372
+ if self.verify_result and '(' in str(self.verify_result):
373
+ verify_detail = str(self.verify_result).split('(')[1].rstrip(')')
374
+
375
+ result = {
376
+ "step": verify_label,
377
+ "elapsed": Utils.ago_str(int(elapsed)),
378
+ "rate": f"{Utils.human(int(rate_bps))}/s",
379
+ "bytes_checked": self.verify_progress,
380
+ "result": self.verify_result,
381
+ }
382
+
383
+ if verify_detail:
384
+ result["verify_detail"] = verify_detail
385
+
386
+ return result
387
+
388
+
389
+ class VerifyZeroTask(VerifyTask):
390
+ """Verify disk contains zeros"""
391
+
392
+ def __init__(self, device_path, total_size, opts=None, verify_pct=2):
393
+ super().__init__(device_path, total_size, opts, verify_pct, expected_pattern="zeroed")
394
+ self.fast_fail = True # Use fast memcmp verification
395
+
396
+ def get_display_name(self):
397
+ """Get display name for zero verification"""
398
+ return "Verify"
399
+
400
+
401
+ class VerifyRandTask(VerifyTask):
402
+ """Verify disk contains random pattern"""
403
+
404
+ def __init__(self, device_path, total_size, opts=None, verify_pct=2):
405
+ super().__init__(device_path, total_size, opts, verify_pct, expected_pattern="random")
406
+ self.fast_fail = False # Use statistical analysis
407
+
408
+ def get_display_name(self):
409
+ """Get display name for random verification"""
410
+ return "Verify"