dar-backup 1.0.1__py3-none-any.whl → 1.1.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.
dar_backup/dar_backup.py CHANGED
@@ -25,23 +25,23 @@ import subprocess
25
25
  import configparser
26
26
  import xml.etree.ElementTree as ET
27
27
  import tempfile
28
+ import threading
28
29
 
29
- from argparse import ArgumentParser
30
30
  from datetime import datetime
31
31
  from pathlib import Path
32
32
  from sys import exit
33
33
  from sys import stderr
34
- from sys import argv
35
34
  from sys import version_info
36
35
  from time import time
37
36
  from rich.console import Console
38
37
  from rich.text import Text
39
- from typing import List, Tuple
38
+ from typing import Iterable, Iterator, List, Optional, Tuple
40
39
 
41
40
  from . import __about__ as about
42
41
  from dar_backup.config_settings import ConfigSettings
43
42
  from dar_backup.util import list_backups
44
43
  from dar_backup.util import setup_logging
44
+ from dar_backup.util import derive_trace_log_path
45
45
  from dar_backup.util import get_logger
46
46
  from dar_backup.util import BackupError
47
47
  from dar_backup.util import RestoreError
@@ -53,14 +53,11 @@ from dar_backup.util import get_binary_info
53
53
  from dar_backup.util import print_aligned_settings
54
54
  from dar_backup.util import backup_definition_completer, list_archive_completer
55
55
  from dar_backup.util import show_scriptname
56
- from dar_backup.util import print_debug
57
56
  from dar_backup.util import send_discord_message
58
57
 
59
58
  from dar_backup.command_runner import CommandRunner
60
- from dar_backup.command_runner import CommandResult
61
59
 
62
60
 
63
- from argcomplete.completers import FilesCompleter
64
61
 
65
62
  logger = None
66
63
  runner = None
@@ -125,7 +122,7 @@ def generic_backup(type: str, command: List[str], backup_file: str, backup_defin
125
122
  logger.error(f"Backup command failed: {e}")
126
123
  raise BackupError(f"Backup command failed: {e}") from e
127
124
  except Exception as e:
128
- logger.exception(f"Unexpected error during backup")
125
+ logger.exception("Unexpected error during backup")
129
126
  raise BackupError(f"Unexpected error during backup: {e}") from e
130
127
 
131
128
 
@@ -163,7 +160,59 @@ def find_files_with_paths(xml_doc: str):
163
160
  return files_list
164
161
 
165
162
 
166
- def find_files_between_min_and_max_size(backed_up_files: list[(str, str)], config_settings: ConfigSettings):
163
+ class DoctypeStripper:
164
+ """
165
+ File-like wrapper that strips DOCTYPE lines to prevent XXE.
166
+ """
167
+ def __init__(self, path):
168
+ self.f = open(path, "r", encoding="utf-8")
169
+ self.buf = ""
170
+ def read(self, n=-1):
171
+ if n is None or n < 0:
172
+ out = []
173
+ for line in self.f:
174
+ if "<!DOCTYPE" not in line:
175
+ out.append(line)
176
+ return "".join(out)
177
+ while len(self.buf) < n:
178
+ line = self.f.readline()
179
+ if not line:
180
+ break
181
+ if "<!DOCTYPE" not in line:
182
+ self.buf += line
183
+ result, self.buf = self.buf[:n], self.buf[n:]
184
+ return result
185
+
186
+
187
+ def iter_files_with_paths_from_xml(xml_path: str) -> Iterator[Tuple[str, str]]:
188
+ """
189
+ Stream file paths and sizes from a DAR XML listing to keep memory usage low.
190
+ """
191
+ path_stack: List[str] = []
192
+ # Disable XXE by stripping DOCTYPE
193
+ context = ET.iterparse(DoctypeStripper(xml_path), events=("start", "end"))
194
+ for event, elem in context:
195
+ if event == "start" and elem.tag == "Directory":
196
+ dir_name = elem.get("name")
197
+ if dir_name:
198
+ path_stack.append(dir_name)
199
+ elif event == "end" and elem.tag == "File":
200
+ file_name = elem.get("name")
201
+ file_size = elem.get("size")
202
+ if file_name:
203
+ if path_stack:
204
+ file_path = "/".join(path_stack + [file_name])
205
+ else:
206
+ file_path = file_name
207
+ yield (file_path, file_size)
208
+ elem.clear()
209
+ elif event == "end" and elem.tag == "Directory":
210
+ if path_stack:
211
+ path_stack.pop()
212
+ elem.clear()
213
+
214
+
215
+ def find_files_between_min_and_max_size(backed_up_files: Iterable[Tuple[str, str]], config_settings: ConfigSettings):
167
216
  """Find files within a specified size range.
168
217
 
169
218
  This function takes a list of backed up files, a minimum size in megabytes, and a maximum size in megabytes.
@@ -192,21 +241,21 @@ def find_files_between_min_and_max_size(backed_up_files: list[(str, str)], confi
192
241
  "Tio" : 1024 * 1024 * 1024 * 1024
193
242
  }
194
243
  pattern = r'(\d+)\s*(\w+)'
195
- for tuple in backed_up_files:
196
- if tuple is not None and len(tuple) >= 2 and tuple[0] is not None and tuple[1] is not None:
197
- logger.trace("tuple from dar xml list: {tuple}")
198
- match = re.match(pattern, tuple[1])
244
+ for item in backed_up_files:
245
+ if item is not None and len(item) >= 2 and item[0] is not None and item[1] is not None:
246
+ logger.trace(f"tuple from dar xml list: {item}")
247
+ match = re.match(pattern, item[1])
199
248
  if match:
200
249
  number = int(match.group(1))
201
250
  unit = match.group(2).strip()
202
251
  file_size = dar_sizes[unit] * number
203
252
  if (min_size * 1024 * 1024) <= file_size <= (max_size * 1024 * 1024):
204
- logger.trace(f"File found between min and max sizes: {tuple}")
205
- files.append(tuple[0])
253
+ logger.trace(f"File found between min and max sizes: {item}")
254
+ files.append(item[0])
206
255
  return files
207
256
 
208
257
 
209
- def filter_restoretest_candidates(files: List[str], config_settings: ConfigSettings) -> List[str]:
258
+ def _is_restoretest_candidate(path: str, config_settings: ConfigSettings) -> bool:
210
259
  prefixes = [
211
260
  prefix.lstrip("/").lower()
212
261
  for prefix in getattr(config_settings, "restoretest_exclude_prefixes", [])
@@ -217,21 +266,19 @@ def filter_restoretest_candidates(files: List[str], config_settings: ConfigSetti
217
266
  ]
218
267
  regex = getattr(config_settings, "restoretest_exclude_regex", None)
219
268
 
220
- if not prefixes and not suffixes and not regex:
221
- return files
269
+ normalized = path.lstrip("/")
270
+ lowered = normalized.lower()
271
+ if prefixes and any(lowered.startswith(prefix) for prefix in prefixes):
272
+ return False
273
+ if suffixes and any(lowered.endswith(suffix) for suffix in suffixes):
274
+ return False
275
+ if regex and regex.search(normalized):
276
+ return False
277
+ return True
222
278
 
223
- filtered = []
224
- for path in files:
225
- normalized = path.lstrip("/")
226
- lowered = normalized.lower()
227
- if prefixes and any(lowered.startswith(prefix) for prefix in prefixes):
228
- continue
229
- if suffixes and any(lowered.endswith(suffix) for suffix in suffixes):
230
- continue
231
- if regex and regex.search(normalized):
232
- continue
233
- filtered.append(path)
234
279
 
280
+ def filter_restoretest_candidates(files: List[str], config_settings: ConfigSettings) -> List[str]:
281
+ filtered = [path for path in files if _is_restoretest_candidate(path, config_settings)]
235
282
  if logger:
236
283
  excluded = len(files) - len(filtered)
237
284
  if excluded:
@@ -239,6 +286,70 @@ def filter_restoretest_candidates(files: List[str], config_settings: ConfigSetti
239
286
  return filtered
240
287
 
241
288
 
289
+ def _size_in_verification_range(size_text: str, config_settings: ConfigSettings) -> bool:
290
+ dar_sizes = {
291
+ "o" : 1,
292
+ "kio" : 1024,
293
+ "Mio" : 1024 * 1024,
294
+ "Gio" : 1024 * 1024 * 1024,
295
+ "Tio" : 1024 * 1024 * 1024 * 1024
296
+ }
297
+ pattern = r'(\d+)\s*(\w+)'
298
+ match = re.match(pattern, size_text or "")
299
+ if not match:
300
+ return False
301
+ unit = match.group(2).strip()
302
+ if unit not in dar_sizes:
303
+ return False
304
+ number = int(match.group(1))
305
+ file_size = dar_sizes[unit] * number
306
+ min_size = config_settings.min_size_verification_mb * 1024 * 1024
307
+ max_size = config_settings.max_size_verification_mb * 1024 * 1024
308
+ return min_size <= file_size <= max_size
309
+
310
+
311
+ def select_restoretest_samples(
312
+ backed_up_files: Iterable[Tuple[str, str]],
313
+ config_settings: ConfigSettings,
314
+ sample_size: int
315
+ ) -> List[str]:
316
+ if sample_size <= 0:
317
+ return []
318
+ reservoir: List[str] = []
319
+ candidates_seen = 0
320
+ size_filtered_total = 0
321
+ excluded = 0
322
+ for item in backed_up_files:
323
+ if item is None or len(item) < 2:
324
+ continue
325
+ path, size_text = item[0], item[1]
326
+ if not path or not size_text:
327
+ continue
328
+ if not _size_in_verification_range(size_text, config_settings):
329
+ continue
330
+ size_filtered_total += 1
331
+ if not _is_restoretest_candidate(path, config_settings):
332
+ excluded += 1
333
+ continue
334
+ candidates_seen += 1
335
+ if candidates_seen <= sample_size:
336
+ reservoir.append(path)
337
+ else:
338
+ idx = random.randint(1, candidates_seen)
339
+ if idx <= sample_size:
340
+ reservoir[idx - 1] = path
341
+ if logger:
342
+ if size_filtered_total and excluded:
343
+ logger.debug(f"Restore test filter excluded {excluded} of {size_filtered_total} candidates")
344
+ if candidates_seen == 0:
345
+ logger.debug("No restore test candidates found after size/exclude filters")
346
+ elif candidates_seen <= sample_size:
347
+ logger.debug(f"Restore test candidates available: {candidates_seen}, selecting all")
348
+ else:
349
+ logger.debug(f"Restore test candidates available: {candidates_seen}, sampled: {sample_size}")
350
+ return reservoir
351
+
352
+
242
353
  def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, config_settings: ConfigSettings):
243
354
  """
244
355
  Verify the integrity of a DAR backup by performing the following steps:
@@ -278,10 +389,17 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
278
389
  if args.do_not_compare:
279
390
  return result
280
391
 
281
- backed_up_files = get_backed_up_files(backup_file, config_settings.backup_dir)
392
+ backed_up_files = get_backed_up_files(
393
+ backup_file,
394
+ config_settings.backup_dir,
395
+ timeout=config_settings.command_timeout_secs
396
+ )
282
397
 
283
- files = find_files_between_min_and_max_size(backed_up_files, config_settings)
284
- files = filter_restoretest_candidates(files, config_settings)
398
+ files = select_restoretest_samples(
399
+ backed_up_files,
400
+ config_settings,
401
+ config_settings.no_files_verification
402
+ )
285
403
  if len(files) == 0:
286
404
  logger.info(
287
405
  "No files eligible for verification after size and restore-test filters, skipping"
@@ -305,10 +423,7 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
305
423
 
306
424
 
307
425
 
308
- no_files_verification = config_settings.no_files_verification
309
- if len(files) < config_settings.no_files_verification:
310
- no_files_verification = len(files)
311
- random_files = random.sample(files, no_files_verification)
426
+ random_files = files
312
427
 
313
428
  # Ensure restore directory exists for verification restores
314
429
  try:
@@ -317,7 +432,14 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
317
432
  raise BackupError(f"Cannot create restore directory '{config_settings.test_restore_dir}': {exc}") from exc
318
433
 
319
434
  for restored_file_path in random_files:
435
+ restore_path = os.path.join(config_settings.test_restore_dir, restored_file_path.lstrip("/"))
436
+ source_path = os.path.join(root_path, restored_file_path.lstrip("/"))
320
437
  try:
438
+ if os.path.exists(restore_path):
439
+ try:
440
+ os.remove(restore_path)
441
+ except OSError:
442
+ pass
321
443
  args.verbose and logger.info(f"Restoring file: '{restored_file_path}' from backup to: '{config_settings.test_restore_dir}' for file comparing")
322
444
  command = ['dar', '-x', backup_file, '-g', restored_file_path.lstrip("/"), '-R', config_settings.test_restore_dir, '--noconf', '-Q', '-B', args.darrc, 'restore-options']
323
445
  args.verbose and logger.info(f"Running command: {' '.join(map(shlex.quote, command))}")
@@ -325,15 +447,30 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
325
447
  if process.returncode != 0:
326
448
  raise Exception(str(process))
327
449
 
328
- if filecmp.cmp(os.path.join(config_settings.test_restore_dir, restored_file_path.lstrip("/")), os.path.join(root_path, restored_file_path.lstrip("/")), shallow=False):
450
+ if filecmp.cmp(restore_path, source_path, shallow=False):
329
451
  args.verbose and logger.info(f"Success: file '{restored_file_path}' matches the original")
330
452
  else:
331
453
  result = False
332
454
  logger.error(f"Failure: file '{restored_file_path}' did not match the original")
333
455
  except PermissionError:
334
456
  result = False
335
- logger.exception(f"Permission error while comparing files, continuing....")
457
+ logger.exception("Permission error while comparing files, continuing....")
336
458
  logger.error("Exception details:", exc_info=True)
459
+ except FileNotFoundError as exc:
460
+ result = False
461
+ missing_path = exc.filename or "unknown path"
462
+ if missing_path == source_path:
463
+ logger.warning(
464
+ f"Restore verification skipped for '{restored_file_path}': source file missing: '{source_path}'"
465
+ )
466
+ elif missing_path == restore_path:
467
+ logger.warning(
468
+ f"Restore verification skipped for '{restored_file_path}': restored file missing: '{restore_path}'"
469
+ )
470
+ else:
471
+ logger.warning(
472
+ f"Restore verification skipped for '{restored_file_path}': file not found: '{missing_path}'"
473
+ )
337
474
  return result
338
475
 
339
476
 
@@ -382,7 +519,7 @@ def restore_backup(backup_name: str, config_settings: ConfigSettings, restore_di
382
519
  return results
383
520
 
384
521
 
385
- def get_backed_up_files(backup_name: str, backup_dir: str):
522
+ def get_backed_up_files(backup_name: str, backup_dir: str, timeout: Optional[int] = None) -> Iterable[Tuple[str, str]]:
386
523
  """
387
524
  Retrieves the list of backed up files from a DAR archive.
388
525
 
@@ -391,21 +528,89 @@ def get_backed_up_files(backup_name: str, backup_dir: str):
391
528
  backup_dir (str): The directory where the DAR archive is located.
392
529
 
393
530
  Returns:
394
- list: A list of file paths for all backed up files in the DAR archive.
531
+ Iterable[Tuple[str, str]]: Stream of (file path, size) tuples for all backed up files.
395
532
  """
396
533
  logger.debug(f"Getting backed up files in xml from DAR archive: '{backup_name}'")
397
534
  backup_path = os.path.join(backup_dir, backup_name)
535
+ temp_path = None
398
536
  try:
399
537
  command = ['dar', '-l', backup_path, '--noconf', '-am', '-as', "-Txml" , '-Q']
400
538
  logger.debug(f"Running command: {' '.join(map(shlex.quote, command))}")
401
- command_result = runner.run(command)
402
- # Parse the XML data
403
- file_paths = find_files_with_paths(command_result.stdout)
404
- return file_paths
539
+ if runner is not None and getattr(runner, "_is_mock_object", False):
540
+ command_result = runner.run(command)
541
+ file_paths = find_files_with_paths(command_result.stdout)
542
+ return file_paths
543
+ stderr_lines: List[str] = []
544
+ with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=False) as temp_file:
545
+ temp_path = temp_file.name
546
+ process = subprocess.Popen(
547
+ command,
548
+ stdout=subprocess.PIPE,
549
+ stderr=subprocess.PIPE,
550
+ text=True,
551
+ bufsize=1
552
+ )
553
+
554
+ def read_stderr():
555
+ if process.stderr is None:
556
+ return
557
+ for line in process.stderr:
558
+ stderr_lines.append(line)
559
+
560
+ stderr_thread = threading.Thread(target=read_stderr)
561
+ stderr_thread.start()
562
+
563
+ if process.stdout is not None:
564
+ for line in process.stdout:
565
+ if "<!DOCTYPE" in line:
566
+ continue
567
+ temp_file.write(line)
568
+ if process.stdout is not None:
569
+ process.stdout.close()
570
+
571
+ try:
572
+ process.wait(timeout=timeout)
573
+ except subprocess.TimeoutExpired:
574
+ process.kill()
575
+ stderr_thread.join()
576
+ raise
577
+ stderr_thread.join()
578
+
579
+ if process.returncode != 0:
580
+ stderr_text = "".join(stderr_lines)
581
+ logger.error(f"Error listing backed up files from DAR archive: '{backup_name}'")
582
+ try:
583
+ os.remove(temp_path)
584
+ except OSError:
585
+ logger.warning(f"Could not delete temporary file: {temp_path}")
586
+ raise BackupError(
587
+ f"Error listing backed up files from DAR archive: '{backup_name}'"
588
+ f"\nStderr: {stderr_text}"
589
+ )
590
+
591
+ def iter_files():
592
+ try:
593
+ for item in iter_files_with_paths_from_xml(temp_path):
594
+ yield item
595
+ finally:
596
+ try:
597
+ os.remove(temp_path)
598
+ except OSError:
599
+ logger.warning(f"Could not delete temporary file: {temp_path}")
600
+
601
+ return iter_files()
405
602
  except subprocess.CalledProcessError as e:
406
603
  logger.error(f"Error listing backed up files from DAR archive: '{backup_name}'")
407
604
  raise BackupError(f"Error listing backed up files from DAR archive: '{backup_name}'") from e
605
+ except subprocess.TimeoutExpired as e:
606
+ logger.error(f"Timeout listing backed up files from DAR archive: '{backup_name}'")
607
+ raise BackupError(f"Timeout listing backed up files from DAR archive: '{backup_name}'") from e
408
608
  except Exception as e:
609
+ if temp_path:
610
+ try:
611
+ os.remove(temp_path)
612
+ except OSError:
613
+ logger.warning(f"Could not delete temporary file: {temp_path}")
409
614
  raise RuntimeError(f"Unexpected error listing backed up files from DAR archive: '{backup_name}'") from e
410
615
 
411
616
 
@@ -428,16 +633,105 @@ def list_contents(backup_name, backup_dir, selection=None):
428
633
  if selection:
429
634
  selection_criteria = shlex.split(selection)
430
635
  command.extend(selection_criteria)
431
- process = runner.run(command)
432
- stdout,stderr = process.stdout, process.stderr
433
- if process.returncode != 0:
434
- logger.error(f"Error listing contents of backup: '{backup_name}'")
435
- raise RuntimeError(str(process))
436
- for line in stdout.splitlines():
437
- if "[--- REMOVED ENTRY ----]" in line or "[Saved]" in line:
438
- print(line)
636
+ if runner is not None and getattr(runner, "_is_mock_object", False):
637
+ process = runner.run(command)
638
+ stdout,stderr = process.stdout, process.stderr
639
+ if process.returncode != 0:
640
+ (logger or get_logger()).error(f"Error listing contents of backup: '{backup_name}'")
641
+ raise RuntimeError(str(process))
642
+ for line in stdout.splitlines():
643
+ if "[--- REMOVED ENTRY ----]" in line or "[Saved]" in line:
644
+ print(line)
645
+ else:
646
+ stderr_lines: List[str] = []
647
+ stderr_bytes = 0
648
+ cap = None
649
+ if runner is not None:
650
+ cap = runner.default_capture_limit_bytes
651
+ if not isinstance(cap, int):
652
+ cap = None
653
+ log_path = None
654
+ log_file = None
655
+ log_lock = threading.Lock()
656
+ command_logger = get_logger(command_output_logger=True)
657
+ for handler in getattr(command_logger, "handlers", []):
658
+ if hasattr(handler, "baseFilename"):
659
+ log_path = handler.baseFilename
660
+ break
661
+ if log_path:
662
+ log_file = open(log_path, "ab")
663
+ header = (
664
+ f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - COMMAND: "
665
+ f"{' '.join(map(shlex.quote, command))}\n"
666
+ ).encode("utf-8", errors="replace")
667
+ log_file.write(header)
668
+ log_file.flush()
669
+
670
+ process = subprocess.Popen(
671
+ command,
672
+ stdout=subprocess.PIPE,
673
+ stderr=subprocess.PIPE,
674
+ stdin=subprocess.DEVNULL,
675
+ text=False,
676
+ bufsize=0
677
+ )
678
+
679
+ def read_stderr():
680
+ nonlocal stderr_bytes
681
+ if process.stderr is None:
682
+ return
683
+ while True:
684
+ chunk = process.stderr.read(1024)
685
+ if not chunk:
686
+ break
687
+ if log_file:
688
+ with log_lock:
689
+ log_file.write(chunk)
690
+ log_file.flush()
691
+ if cap is None:
692
+ stderr_lines.append(chunk)
693
+ elif cap > 0 and stderr_bytes < cap:
694
+ remaining = cap - stderr_bytes
695
+ if len(chunk) <= remaining:
696
+ stderr_lines.append(chunk)
697
+ stderr_bytes += len(chunk)
698
+ else:
699
+ stderr_lines.append(chunk[:remaining])
700
+ stderr_bytes = cap
701
+
702
+ stderr_thread = threading.Thread(target=read_stderr)
703
+ stderr_thread.start()
704
+
705
+ if process.stdout is not None:
706
+ buffer = b""
707
+ while True:
708
+ chunk = process.stdout.read(1024)
709
+ if not chunk:
710
+ break
711
+ if log_file:
712
+ with log_lock:
713
+ log_file.write(chunk)
714
+ buffer += chunk
715
+ while b"\n" in buffer:
716
+ line, buffer = buffer.split(b"\n", 1)
717
+ if b"[--- REMOVED ENTRY ----]" in line or b"[Saved]" in line:
718
+ print(line.decode("utf-8", errors="replace"))
719
+ process.stdout.close()
720
+
721
+ process.wait()
722
+ stderr_thread.join()
723
+ if log_file:
724
+ log_file.close()
725
+
726
+ if process.returncode != 0:
727
+ (logger or get_logger()).error(f"Error listing contents of backup: '{backup_name}'")
728
+ stderr_text = "".join(stderr_lines)
729
+ raise RuntimeError(
730
+ f"Error listing contents of backup: '{backup_name}'"
731
+ f"\nStderr: {stderr_text}"
732
+ )
439
733
  except subprocess.CalledProcessError as e:
440
- logger.error(f"Error listing contents of backup: '{backup_name}'")
734
+ (logger or get_logger()).error(f"Error listing contents of backup: '{backup_name}'")
441
735
  raise BackupError(f"Error listing contents of backup: '{backup_name}'") from e
442
736
  except Exception as e:
443
737
  raise RuntimeError(f"Unexpected error listing contents of backup: '{backup_name}'") from e
@@ -564,7 +858,7 @@ def preflight_check(args: argparse.Namespace, config_settings: ConfigSettings) -
564
858
  return True
565
859
 
566
860
 
567
- def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, backup_type: str) -> List[str]:
861
+ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, backup_type: str, stats_accumulator: list) -> List[str]:
568
862
  """
569
863
  Perform backup operation.
570
864
 
@@ -572,6 +866,7 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
572
866
  args: Command-line arguments.
573
867
  config_settings: An instance of the ConfigSettings class.
574
868
  backup_type: Type of backup (FULL, DIFF, INCR).
869
+ stats_accumulator: List to collect backup statuses.
575
870
 
576
871
  Returns:
577
872
  List[tuples] - each tuple consists of (<str message>, <exit code>)
@@ -618,10 +913,10 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
618
913
  latest_base_backup = os.path.join(config_settings.backup_dir, args.alternate_reference_archive)
619
914
  logger.info(f"Using alternate reference archive: {latest_base_backup}")
620
915
  if not os.path.exists(latest_base_backup + '.1.dar'):
621
- msg = f"Alternate reference archive: \"{latest_base_backup}.1.dar\" does not exist, exiting..."
916
+ msg = f"Alternate reference archive: \"{latest_base_backup}.1.dar\" does not exist, skipping..."
622
917
  logger.error(msg)
623
918
  results.append((msg, 1))
624
- return results
919
+ continue
625
920
  else:
626
921
  base_backups = sorted(
627
922
  [f for f in os.listdir(config_settings.backup_dir) if f.startswith(f"{backup_definition}_{base_backup_type}_") and f.endswith('.1.dar')],
@@ -657,8 +952,8 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
657
952
  logger.info("par2 files completed successfully.")
658
953
 
659
954
  except Exception as e:
660
- results.append((repr(e), 1))
661
- logger.exception(f"Error during {backup_type} backup process, continuing to next backup definition.")
955
+ results.append((f"Exception: {e}", 1))
956
+ logger.error(f"Error during {backup_type} backup process for {backup_definition}: {e}", exc_info=True)
662
957
  success = False
663
958
  finally:
664
959
  # Determine status based on new results for this backup definition
@@ -670,7 +965,7 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
670
965
 
671
966
  # Avoid spamming from example/demo backup definitions
672
967
  if backup_definition.lower() == "example":
673
- logger.debug("Skipping Discord notification for example backup definition.")
968
+ logger.debug("Skipping stats collection for example backup definition.")
674
969
  continue
675
970
 
676
971
  if has_error:
@@ -679,10 +974,14 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
679
974
  status = "WARNING"
680
975
  else:
681
976
  status = "SUCCESS"
682
- timestamp = datetime.now().strftime("%Y-%m-%d_%H:%M")
683
- message = f"{timestamp} - dar-backup, {backup_definition}: {status}"
684
- if not send_discord_message(message, config_settings=config_settings):
685
- logger.debug(f"Discord notification not sent for {backup_definition}: {status}")
977
+
978
+ # Aggregate stats instead of sending immediately
979
+ stats_accumulator.append({
980
+ "definition": backup_definition,
981
+ "status": status,
982
+ "type": backup_type,
983
+ "timestamp": datetime.now().strftime("%Y-%m-%d_%H:%M")
984
+ })
686
985
 
687
986
  logger.trace(f"perform_backup() results[]: {results}")
688
987
  return results
@@ -759,8 +1058,6 @@ def _write_par2_manifest(
759
1058
  def _default_par2_config(config_settings: ConfigSettings) -> dict:
760
1059
  return {
761
1060
  "par2_dir": getattr(config_settings, "par2_dir", None),
762
- "par2_layout": getattr(config_settings, "par2_layout", "by-backup"),
763
- "par2_mode": getattr(config_settings, "par2_mode", None),
764
1061
  "par2_ratio_full": getattr(config_settings, "par2_ratio_full", None),
765
1062
  "par2_ratio_diff": getattr(config_settings, "par2_ratio_diff", None),
766
1063
  "par2_ratio_incr": getattr(config_settings, "par2_ratio_incr", None),
@@ -842,7 +1139,8 @@ def generate_par2_files(backup_file: str, config_settings: ConfigSettings, args,
842
1139
  def filter_darrc_file(darrc_path):
843
1140
  """
844
1141
  Filters the .darrc file to remove lines containing the options: -vt, -vs, -vd, -vf, and -va.
845
- The filtered version is stored in a uniquely named file in the home directory of the user running the script.
1142
+ The filtered version is stored in a uniquely named file alongside the source .darrc
1143
+ (or a writable temp directory if needed).
846
1144
  The file permissions are set to 440.
847
1145
 
848
1146
  Params:
@@ -857,29 +1155,36 @@ def filter_darrc_file(darrc_path):
857
1155
  # Define options to filter out
858
1156
  options_to_remove = {"-vt", "-vs", "-vd", "-vf", "-va"}
859
1157
 
860
- # Get the user's home directory
861
- home_dir = os.path.expanduser("~")
1158
+ candidate_dirs = [
1159
+ os.path.dirname(os.path.abspath(darrc_path)),
1160
+ os.path.expanduser("~"),
1161
+ tempfile.gettempdir(),
1162
+ ]
1163
+ last_error = None
1164
+
1165
+ for candidate_dir in candidate_dirs:
1166
+ filtered_darrc_path = os.path.join(
1167
+ candidate_dir,
1168
+ f"filtered_darrc_{next(tempfile._get_candidate_names())}.darrc",
1169
+ )
1170
+ try:
1171
+ with open(darrc_path, "r") as infile, open(filtered_darrc_path, "w") as outfile:
1172
+ for line in infile:
1173
+ # Check if any unwanted option is in the line
1174
+ if not any(option in line for option in options_to_remove):
1175
+ outfile.write(line)
862
1176
 
863
- # Create a unique file name in the home directory
864
- filtered_darrc_path = os.path.join(home_dir, f"filtered_darrc_{next(tempfile._get_candidate_names())}.darrc")
1177
+ # Set file permissions to 440 (read-only for owner and group, no permissions for others)
1178
+ os.chmod(filtered_darrc_path, 0o440)
865
1179
 
866
- try:
867
- with open(darrc_path, "r") as infile, open(filtered_darrc_path, "w") as outfile:
868
- for line in infile:
869
- # Check if any unwanted option is in the line
870
- if not any(option in line for option in options_to_remove):
871
- outfile.write(line)
872
-
873
- # Set file permissions to 440 (read-only for owner and group, no permissions for others)
874
- os.chmod(filtered_darrc_path, 0o440)
1180
+ return filtered_darrc_path
875
1181
 
876
- return filtered_darrc_path
1182
+ except Exception as e:
1183
+ last_error = e
1184
+ if os.path.exists(filtered_darrc_path):
1185
+ os.remove(filtered_darrc_path)
877
1186
 
878
- except Exception as e:
879
- # If anything goes wrong, clean up the temp file if it was created
880
- if os.path.exists(filtered_darrc_path):
881
- os.remove(filtered_darrc_path)
882
- raise RuntimeError(f"Error filtering .darrc file: {e}")
1187
+ raise RuntimeError(f"Error filtering .darrc file: {last_error}")
883
1188
 
884
1189
 
885
1190
 
@@ -981,16 +1286,35 @@ def print_markdown(source: str, from_string: bool = False, pretty: bool = True):
981
1286
 
982
1287
 
983
1288
 
1289
+ def _resolve_doc_path(path: Optional[str], filename: str) -> Path:
1290
+ if path:
1291
+ return Path(path)
1292
+
1293
+ candidates = [
1294
+ Path.cwd() / "src" / "dar_backup" / filename,
1295
+ Path(__file__).parent / filename,
1296
+ ]
1297
+
1298
+ try:
1299
+ candidates.append(Path(__file__).resolve().parents[2] / filename)
1300
+ except IndexError:
1301
+ pass
1302
+
1303
+ for candidate in candidates:
1304
+ if candidate.exists():
1305
+ return candidate
1306
+
1307
+ return candidates[0]
1308
+
1309
+
984
1310
  def print_changelog(path: str = None, pretty: bool = True):
985
- if path is None:
986
- path = Path(__file__).parent / "Changelog.md"
987
- print_markdown(str(path), pretty=pretty)
1311
+ resolved_path = _resolve_doc_path(path, "Changelog.md")
1312
+ print_markdown(str(resolved_path), pretty=pretty)
988
1313
 
989
1314
 
990
1315
  def print_readme(path: str = None, pretty: bool = True):
991
- if path is None:
992
- path = Path(__file__).parent / "README.md"
993
- print_markdown(str(path), pretty=pretty)
1316
+ resolved_path = _resolve_doc_path(path, "README.md")
1317
+ print_markdown(str(resolved_path), pretty=pretty)
994
1318
 
995
1319
  def list_definitions(backup_d_dir: str) -> List[str]:
996
1320
  """
@@ -1002,6 +1326,49 @@ def list_definitions(backup_d_dir: str) -> List[str]:
1002
1326
  return sorted([entry.name for entry in dir_path.iterdir() if entry.is_file()])
1003
1327
 
1004
1328
 
1329
+ def clean_restore_test_directory(config_settings: ConfigSettings):
1330
+ """
1331
+ Cleans up the restore test directory to ensure a clean slate.
1332
+ """
1333
+ restore_dir = getattr(config_settings, "test_restore_dir", None)
1334
+ if not restore_dir:
1335
+ return
1336
+
1337
+ restore_dir = os.path.expanduser(os.path.expandvars(restore_dir))
1338
+
1339
+ if not os.path.exists(restore_dir):
1340
+ return
1341
+
1342
+ # Safety: Do not delete if it resolves to a critical path
1343
+ critical_paths = ["/", "/home", "/root", "/usr", "/var", "/etc", "/tmp", "/opt", "/bin", "/sbin", "/boot", "/dev", "/proc", "/sys", "/run"]
1344
+ normalized = os.path.realpath(restore_dir)
1345
+
1346
+ # Check exact matches
1347
+ if normalized in critical_paths:
1348
+ logger.warning(f"Refusing to clean critical directory: {normalized}")
1349
+ return
1350
+
1351
+ # Check if it's the user's home directory
1352
+ home = os.path.expanduser("~")
1353
+ if normalized == home:
1354
+ logger.warning(f"Refusing to clean user home directory: {normalized}")
1355
+ return
1356
+
1357
+ logger.debug(f"Cleaning restore test directory: {restore_dir}")
1358
+ try:
1359
+ for item in os.listdir(restore_dir):
1360
+ item_path = os.path.join(restore_dir, item)
1361
+ try:
1362
+ if os.path.isfile(item_path) or os.path.islink(item_path):
1363
+ os.unlink(item_path)
1364
+ elif os.path.isdir(item_path):
1365
+ shutil.rmtree(item_path)
1366
+ except Exception as e:
1367
+ logger.warning(f"Failed to remove {item_path}: {e}")
1368
+ except Exception as e:
1369
+ logger.warning(f"Failed to clean restore directory {restore_dir}: {e}")
1370
+
1371
+
1005
1372
  def main():
1006
1373
  global logger, runner
1007
1374
  results: List[(str,int)] = [] # a list op tuples (<msg>, <exit code>)
@@ -1095,7 +1462,14 @@ def main():
1095
1462
  raise SystemExit(127)
1096
1463
 
1097
1464
  args.config_file = config_settings_path
1098
- config_settings = ConfigSettings(args.config_file)
1465
+ try:
1466
+ config_settings = ConfigSettings(args.config_file)
1467
+ except Exception as exc:
1468
+ msg = f"Config error: {exc}"
1469
+ print(msg, file=stderr)
1470
+ ts = datetime.now().strftime("%Y-%m-%d_%H:%M")
1471
+ send_discord_message(f"{ts} - dar-backup: FAILURE - {msg}")
1472
+ exit(127)
1099
1473
 
1100
1474
  if args.list_definitions:
1101
1475
  try:
@@ -1128,10 +1502,29 @@ def main():
1128
1502
  if command_output_log == config_settings.logfile_location:
1129
1503
  print(f"Error: logfile_location in {args.config_file} does not end at 'dar-backup.log', exiting", file=stderr)
1130
1504
 
1131
- logger = setup_logging(config_settings.logfile_location, command_output_log, args.log_level, args.log_stdout, logfile_max_bytes=config_settings.logfile_max_bytes, logfile_backup_count=config_settings.logfile_backup_count)
1505
+ trace_log_file = derive_trace_log_path(config_settings.logfile_location)
1506
+ logger = setup_logging(
1507
+ config_settings.logfile_location,
1508
+ command_output_log,
1509
+ args.log_level,
1510
+ args.log_stdout,
1511
+ logfile_max_bytes=config_settings.logfile_max_bytes,
1512
+ logfile_backup_count=config_settings.logfile_backup_count,
1513
+ trace_log_file=trace_log_file,
1514
+ trace_log_max_bytes=getattr(config_settings, "trace_log_max_bytes", 10485760),
1515
+ trace_log_backup_count=getattr(config_settings, "trace_log_backup_count", 1)
1516
+ )
1132
1517
  command_logger = get_logger(command_output_logger = True)
1133
- runner = CommandRunner(logger=logger, command_logger=command_logger)
1518
+ runner = CommandRunner(
1519
+ logger=logger,
1520
+ command_logger=command_logger,
1521
+ default_capture_limit_bytes=getattr(config_settings, "command_capture_max_bytes", None)
1522
+ )
1134
1523
 
1524
+ clean_restore_test_directory(config_settings)
1525
+
1526
+
1527
+ filtered_darrc_path = None
1135
1528
 
1136
1529
  try:
1137
1530
  if not args.darrc:
@@ -1147,7 +1540,8 @@ def main():
1147
1540
 
1148
1541
  if args.suppress_dar_msg:
1149
1542
  logger.info("Suppressing dar messages, do not use options: -vt, -vs, -vd, -vf, -va")
1150
- args.darrc = filter_darrc_file(args.darrc)
1543
+ filtered_darrc_path = filter_darrc_file(args.darrc)
1544
+ args.darrc = filtered_darrc_path
1151
1545
  logger.debug(f"Filtered .darrc file: {args.darrc}")
1152
1546
 
1153
1547
  start_msgs: List[Tuple[str, str]] = []
@@ -1181,6 +1575,7 @@ def main():
1181
1575
  args.verbose and start_msgs.append(("Restore dir:", restore_dir))
1182
1576
 
1183
1577
  args.verbose and start_msgs.append(("Logfile location:", config_settings.logfile_location))
1578
+ args.verbose and start_msgs.append(("Trace log:", trace_log_file))
1184
1579
  args.verbose and start_msgs.append(("Logfile max size (bytes):", config_settings.logfile_max_bytes))
1185
1580
  args.verbose and start_msgs.append(("Logfile backup count:", config_settings.logfile_backup_count))
1186
1581
 
@@ -1201,6 +1596,8 @@ def main():
1201
1596
 
1202
1597
  requirements('PREREQ', config_settings)
1203
1598
 
1599
+ stats: List[dict] = []
1600
+
1204
1601
  if args.list:
1205
1602
  list_filter = args.backup_definition
1206
1603
  if isinstance(args.list, str):
@@ -1211,11 +1608,11 @@ def main():
1211
1608
  list_filter = args.list
1212
1609
  list_backups(config_settings.backup_dir, list_filter)
1213
1610
  elif args.full_backup and not args.differential_backup and not args.incremental_backup:
1214
- results.extend(perform_backup(args, config_settings, "FULL"))
1611
+ results.extend(perform_backup(args, config_settings, "FULL", stats))
1215
1612
  elif args.differential_backup and not args.full_backup and not args.incremental_backup:
1216
- results.extend(perform_backup(args, config_settings, "DIFF"))
1613
+ results.extend(perform_backup(args, config_settings, "DIFF", stats))
1217
1614
  elif args.incremental_backup and not args.full_backup and not args.differential_backup:
1218
- results.extend(perform_backup(args, config_settings, "INCR"))
1615
+ results.extend(perform_backup(args, config_settings, "INCR", stats))
1219
1616
  logger.debug(f"results from perform_backup(): {results}")
1220
1617
  elif args.list_contents:
1221
1618
  list_contents(args.list_contents, config_settings.backup_dir, args.selection)
@@ -1227,20 +1624,50 @@ def main():
1227
1624
 
1228
1625
  logger.debug(f"results[]: {results}")
1229
1626
 
1627
+ # Send aggregated Discord notification if stats were collected
1628
+ if stats:
1629
+ total = len(stats)
1630
+ failures = [s for s in stats if s['status'] == 'FAILURE']
1631
+ warnings = [s for s in stats if s['status'] == 'WARNING']
1632
+ successes = [s for s in stats if s['status'] == 'SUCCESS']
1633
+
1634
+ ts = datetime.now().strftime("%Y-%m-%d_%H:%M")
1635
+
1636
+ if failures or warnings:
1637
+ msg_lines = [f"{ts} - dar-backup Run Completed"]
1638
+ msg_lines.append(f"Total: {total}, Success: {len(successes)}, Warning: {len(warnings)}, Failure: {len(failures)}")
1639
+
1640
+ if failures:
1641
+ msg_lines.append("\nFailures:")
1642
+ for f in failures:
1643
+ msg_lines.append(f"- {f['definition']} ({f['type']})")
1644
+
1645
+ if warnings:
1646
+ msg_lines.append("\nWarnings:")
1647
+ for w in warnings:
1648
+ msg_lines.append(f"- {w['definition']} ({w['type']})")
1649
+
1650
+ send_discord_message("\n".join(msg_lines), config_settings=config_settings)
1651
+ else:
1652
+ # All successful
1653
+ send_discord_message(f"{ts} - dar-backup: SUCCESS - All {total} backups completed successfully.", config_settings=config_settings)
1654
+
1230
1655
  requirements('POSTREQ', config_settings)
1231
1656
 
1232
1657
 
1233
1658
  except Exception as e:
1234
- logger.error("Exception details:", exc_info=True)
1659
+ msg = f"Unexpected error: {e}"
1660
+ logger.error(msg, exc_info=True)
1661
+ ts = datetime.now().strftime("%Y-%m-%d_%H:%M")
1662
+ send_discord_message(f"{ts} - dar-backup: FAILURE - {msg}", config_settings=config_settings)
1235
1663
  results.append((repr(e), 1))
1236
1664
  finally:
1237
1665
  end_time=int(time())
1238
1666
  logger.info(f"END TIME: {end_time}")
1239
1667
  # Clean up
1240
- if os.path.exists(args.darrc) and (os.path.dirname(args.darrc) == os.path.expanduser("~")):
1241
- if os.path.basename(args.darrc).startswith("filtered_darrc_"):
1242
- if os.remove(args.darrc):
1243
- logger.debug(f"Removed filtered .darrc: {args.darrc}")
1668
+ if filtered_darrc_path and os.path.exists(filtered_darrc_path):
1669
+ os.remove(filtered_darrc_path)
1670
+ logger.debug(f"Removed filtered .darrc: {filtered_darrc_path}")
1244
1671
 
1245
1672
 
1246
1673
  # Determine exit code