dar-backup 1.0.0.1__py3-none-any.whl → 1.0.1__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/manager.py CHANGED
@@ -35,6 +35,7 @@ from . import __about__ as about
35
35
  from dar_backup.config_settings import ConfigSettings
36
36
  from dar_backup.util import setup_logging
37
37
  from dar_backup.util import CommandResult
38
+ from dar_backup.util import get_config_file
38
39
  from dar_backup.util import get_logger
39
40
  from dar_backup.util import get_binary_info
40
41
  from dar_backup.util import show_version
@@ -47,6 +48,7 @@ from dar_backup.command_runner import CommandResult
47
48
  from dar_backup.util import backup_definition_completer, list_archive_completer, archive_content_completer, add_specific_archive_completer
48
49
 
49
50
  from datetime import datetime
51
+ from sys import stderr
50
52
  from time import time
51
53
  from typing import Dict, List, NamedTuple, Tuple
52
54
 
@@ -492,7 +494,7 @@ def remove_specific_archive(archive: str, config_settings: ConfigSettings) -> in
492
494
 
493
495
  def build_arg_parser():
494
496
  parser = argparse.ArgumentParser(description="Creates/maintains `dar` database catalogs")
495
- parser.add_argument('-c', '--config-file', type=str, help="Path to 'dar-backup.conf'", default='~/.config/dar-backup/dar-backup.conf')
497
+ parser.add_argument('-c', '--config-file', type=str, help="Path to 'dar-backup.conf'", default=None)
496
498
  parser.add_argument('--create-db', action='store_true', help='Create missing databases for all backup definitions')
497
499
  parser.add_argument('--alternate-archive-dir', type=str, help='Use this directory instead of BACKUP_DIR in config file')
498
500
  parser.add_argument('--add-dir', type=str, help='Add all archive catalogs in this directory to databases')
@@ -536,7 +538,12 @@ def main():
536
538
  show_version()
537
539
  sys.exit(0)
538
540
 
539
- args.config_file = os.path.expanduser(os.path.expandvars(args.config_file))
541
+ config_settings_path = get_config_file(args)
542
+ if not (os.path.isfile(config_settings_path) and os.access(config_settings_path, os.R_OK)):
543
+ print(f"Config file {config_settings_path} must exist and be readable.", file=stderr)
544
+ raise SystemExit(127)
545
+ args.config_file = config_settings_path
546
+
540
547
  config_settings = ConfigSettings(args.config_file)
541
548
 
542
549
  if not os.path.dirname(config_settings.logfile_location):
@@ -554,7 +561,6 @@ def main():
554
561
  start_time = int(time())
555
562
 
556
563
  start_msgs.append((f"{show_scriptname()}:", about.__version__))
557
- logger.info(f"START TIME: {start_time}")
558
564
  logger.debug(f"Command line: {get_invocation_command_line()}")
559
565
  logger.debug(f"`args`:\n{args}")
560
566
  logger.debug(f"`config_settings`:\n{config_settings}")
dar_backup/util.py CHANGED
@@ -14,6 +14,7 @@ import locale
14
14
  import configparser
15
15
  import inspect
16
16
  import logging
17
+ import json
17
18
 
18
19
  import os
19
20
  import re
@@ -23,12 +24,16 @@ import shutil
23
24
  import sys
24
25
  import threading
25
26
  import traceback
27
+ import urllib.error
28
+ import urllib.request
26
29
 
27
30
  import dar_backup.__about__ as about
28
31
 
29
32
 
33
+
30
34
  from argcomplete.completers import ChoicesCompleter
31
- from datetime import datetime
35
+ from datetime import datetime, date
36
+ from datetime import date
32
37
  from dar_backup.config_settings import ConfigSettings
33
38
  from logging.handlers import RotatingFileHandler
34
39
  from pathlib import Path
@@ -136,15 +141,29 @@ def get_logger(command_output_logger: bool = False) -> logging.Logger:
136
141
  return secondary_logger if command_output_logger else logger
137
142
 
138
143
 
144
+ def _default_completer_logfile() -> str:
145
+ try:
146
+ uid = os.getuid()
147
+ except AttributeError:
148
+ uid = None
149
+ suffix = str(uid) if uid is not None else "unknown"
150
+ return f"/tmp/dar_backup_completer_{suffix}.log"
151
+
152
+
139
153
  # Setup completer logger only once
140
- def _setup_completer_logger(logfile="/tmp/dar_backup_completer.log"):
154
+ def _setup_completer_logger(logfile: str = None):
141
155
  logger = logging.getLogger("completer")
142
156
  if not logger.handlers:
143
- handler = logging.FileHandler(logfile)
144
- formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
145
- handler.setFormatter(formatter)
146
- logger.addHandler(handler)
147
- logger.setLevel(logging.DEBUG)
157
+ try:
158
+ logfile = logfile or _default_completer_logfile()
159
+ handler = logging.FileHandler(logfile)
160
+ formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
161
+ handler.setFormatter(formatter)
162
+ logger.addHandler(handler)
163
+ logger.setLevel(logging.DEBUG)
164
+ except Exception:
165
+ logger.addHandler(logging.NullHandler())
166
+ logger.setLevel(logging.DEBUG)
148
167
  return logger
149
168
 
150
169
  # Singleton logger for completer debugging
@@ -200,10 +219,80 @@ def show_version():
200
219
  print(f"{script_name} source code is here: https://github.com/per2jensen/dar-backup")
201
220
  print(about.__license__)
202
221
 
222
+
223
+ def send_discord_message(
224
+ content: str,
225
+ config_settings: typing.Optional[ConfigSettings] = None,
226
+ timeout_seconds: int = 10
227
+ ) -> bool:
228
+ """
229
+ Send a message to a Discord webhook if configured either in the config file or via environment.
230
+
231
+ The environment varible DAR_BACKUP_DISCORD_WEBHOOK_URL, when set, takes precedence over the config file variable
232
+ with the same name. If neither is defined, the function logs an info-level message and returns False.
233
+
234
+ Returns:
235
+ bool: True if the message was sent successfully, otherwise False.
236
+ """
237
+ log = get_logger()
238
+
239
+ config_webhook = getattr(config_settings, "dar_backup_discord_webhook_url", None) if config_settings else None
240
+ env_webhook = os.environ.get("DAR_BACKUP_DISCORD_WEBHOOK_URL")
241
+
242
+ webhook_url = env_webhook or config_webhook
243
+ source = "environment" if env_webhook else ("config file" if config_webhook else None)
244
+
245
+ if not webhook_url:
246
+ log and log.info("Discord message not sent: DAR_BACKUP_DISCORD_WEBHOOK_URL not configured.")
247
+ return False
248
+
249
+ payload = json.dumps({"content": content}).encode("utf-8")
250
+ user_agent = f"dar-backup/{about.__version__}"
251
+
252
+ request = urllib.request.Request(
253
+ webhook_url,
254
+ data=payload,
255
+ headers={
256
+ "Accept": "application/json",
257
+ "Content-Type": "application/json",
258
+ "User-Agent": user_agent,
259
+ },
260
+ method="POST",
261
+ )
262
+
263
+ try:
264
+ with urllib.request.urlopen(request, timeout=timeout_seconds):
265
+ pass
266
+ log and log.debug(f"Discord webhook message sent using {source}.")
267
+ return True
268
+ except urllib.error.HTTPError as exc:
269
+ # Attempt to read a short error body for diagnostics
270
+ body = None
271
+ try:
272
+ body = exc.read().decode(errors="replace")
273
+ except Exception:
274
+ body = None
275
+ detail = f" body='{body.strip()}'" if body else ""
276
+ message = f"Discord webhook HTTP error {exc.code}: {exc.reason}{detail}"
277
+ if log:
278
+ log.error(message)
279
+ else:
280
+ print(message, file=sys.stderr)
281
+ except Exception as exc:
282
+ message = f"Failed to send Discord webhook message: {exc}"
283
+ if log:
284
+ log.error(message)
285
+ else:
286
+ print(message, file=sys.stderr)
287
+
288
+ return False
289
+
290
+
203
291
  def extract_version(output):
204
292
  match = re.search(r'(\d+\.\d+(\.\d+)?)', output)
205
293
  return match.group(1) if match else "unknown"
206
294
 
295
+
207
296
  def get_binary_info(command):
208
297
  """
209
298
  Return information about a binary command.
@@ -428,16 +517,14 @@ def expand_path(path: str) -> str:
428
517
  return os.path.expanduser(os.path.expandvars(path))
429
518
 
430
519
 
431
-
432
520
  def backup_definition_completer(prefix, parsed_args, **kwargs):
433
- config_path = getattr(parsed_args, 'config_file', '~/.config/dar-backup/dar-backup.conf')
434
- config_path = expand_path(config_path)
435
- config_file = os.path.expanduser(config_path)
436
521
  try:
522
+ config_file = get_config_file(parsed_args)
437
523
  config = ConfigSettings(config_file)
438
524
  backup_d_dir = os.path.expanduser(config.backup_d_dir)
439
525
  return [f for f in os.listdir(backup_d_dir) if f.startswith(prefix)]
440
526
  except Exception:
527
+ completer_logger.exception("backup_definition_completer failed")
441
528
  return []
442
529
 
443
530
 
@@ -450,46 +537,84 @@ def extract_backup_definition_fallback() -> str:
450
537
  str: The value of the --backup-definition argument if found, else an empty string.
451
538
  """
452
539
  comp_line = os.environ.get("COMP_LINE", "")
453
- # Match both "--backup-definition VALUE" and "-d VALUE"
454
- match = re.search(r"(--backup-definition|-d)\s+([^\s]+)", comp_line)
455
- if match:
456
- return match.group(2)
540
+ try:
541
+ tokens = shlex.split(comp_line)
542
+ except ValueError:
543
+ tokens = comp_line.split()
544
+
545
+ for i, token in enumerate(tokens):
546
+ if token in ("-d", "--backup-definition", "--backup-def"):
547
+ if i + 1 < len(tokens):
548
+ return tokens[i + 1]
549
+ elif token.startswith(("--backup-definition=", "--backup-def=", "-d=")):
550
+ return token.split("=", 1)[1]
457
551
  return ""
458
552
 
459
553
 
460
554
 
461
555
 
462
556
  def list_archive_completer(prefix, parsed_args, **kwargs):
463
- import os
464
- import configparser
465
- from dar_backup.util import extract_backup_definition_fallback
557
+ try:
558
+ import os
559
+ import configparser
560
+ from dar_backup.util import extract_backup_definition_fallback
561
+
562
+ comp_line = os.environ.get("COMP_LINE", "")
563
+ if "cleanup" in comp_line and "--cleanup-specific-archives" not in comp_line:
564
+ return []
565
+
566
+ backup_def = (
567
+ getattr(parsed_args, "backup_definition", None)
568
+ or getattr(parsed_args, "backup_def", None)
569
+ or extract_backup_definition_fallback()
570
+ )
571
+ head, last = split_archive_list_prefix(prefix)
572
+ config_path = get_config_file(parsed_args)
573
+ if not os.path.exists(config_path):
574
+ return []
466
575
 
467
- backup_def = getattr(parsed_args, "backup_definition", None) or extract_backup_definition_fallback()
468
- config_path = getattr(parsed_args, "config_file", None) or "~/.config/dar-backup/dar-backup.conf"
576
+ config = configparser.ConfigParser()
577
+ config.read(config_path)
578
+ backup_dir = config.get("DIRECTORIES", "BACKUP_DIR", fallback="")
579
+ backup_dir = os.path.expanduser(os.path.expandvars(backup_dir))
469
580
 
470
- config_path = os.path.expanduser(os.path.expandvars(config_path))
471
- if not os.path.exists(config_path):
472
- return []
581
+ if not os.path.isdir(backup_dir):
582
+ return []
473
583
 
474
- config = configparser.ConfigParser()
475
- config.read(config_path)
476
- backup_dir = config.get("DIRECTORIES", "BACKUP_DIR", fallback="")
477
- backup_dir = os.path.expanduser(os.path.expandvars(backup_dir))
584
+ files = os.listdir(backup_dir)
585
+ archive_re = re.compile(rf"^{re.escape(backup_def)}_.+_\d{{4}}-\d{{2}}-\d{{2}}\.1\.dar$") if backup_def else re.compile(r".+_\d{4}-\d{2}-\d{2}\.1\.dar$")
478
586
 
479
- if not os.path.isdir(backup_dir):
480
- return []
587
+ completions = []
588
+ for fname in files:
589
+ if not archive_re.match(fname):
590
+ continue
591
+ base = fname.rsplit(".1.dar", 1)[0]
592
+ if last and not base.startswith(last):
593
+ continue
594
+ if head:
595
+ completions.append(f"{head}, {base}")
596
+ else:
597
+ completions.append(base)
481
598
 
482
- files = os.listdir(backup_dir)
483
- archive_re = re.compile(rf"^{re.escape(backup_def)}_.+_\d{{4}}-\d{{2}}-\d{{2}}\.1\.dar$") if backup_def else re.compile(r".+_\d{4}-\d{2}-\d{2}\.1\.dar$")
599
+ completions = sorted(set(completions), key=sort_key)
600
+ return completions or ["[no matching archives]"]
601
+ except Exception:
602
+ completer_logger.exception("list_archive_completer failed")
603
+ return []
484
604
 
485
- completions = [
486
- f.rsplit(".1.dar", 1)[0]
487
- for f in files
488
- if archive_re.match(f)
489
- ]
490
605
 
491
- completions = sorted(set(completions), key=sort_key)
492
- return completions or ["[no matching archives]"]
606
+ def split_archive_list_prefix(prefix: str) -> tuple[str, str]:
607
+ """
608
+ Split a comma-separated archive list into (head, last).
609
+ Strips whitespace so completions don't include leading/trailing spaces.
610
+ """
611
+ if not prefix or "," not in prefix:
612
+ return ("", prefix.strip())
613
+ parts = [part.strip() for part in prefix.split(",")]
614
+ head_parts = [part for part in parts[:-1] if part]
615
+ head = ", ".join(head_parts)
616
+ last = parts[-1]
617
+ return (head, last)
493
618
 
494
619
 
495
620
 
@@ -511,6 +636,7 @@ def sort_key(archive_name: str):
511
636
  completer_logger.debug(f"Archive: {archive_name}, Def: {def_name}, Date: {date}")
512
637
  return (def_name, date)
513
638
  except Exception:
639
+ completer_logger.exception("sort_key failed")
514
640
  return (archive_name, datetime.min)
515
641
 
516
642
 
@@ -523,52 +649,56 @@ def archive_content_completer(prefix, parsed_args, **kwargs):
523
649
  Only entries found in the catalog database (via `dar_manager --list`) are shown.
524
650
  """
525
651
 
526
- from dar_backup.config_settings import ConfigSettings
527
- import subprocess
528
- import re
529
- import os
530
- from datetime import datetime
531
-
532
- # Expand config path
533
- config_file = expand_path(getattr(parsed_args, "config_file", "~/.config/dar-backup/dar-backup.conf"))
534
- config = ConfigSettings(config_file=config_file)
535
- #db_dir = expand_path((getattr(config, 'manager_db_dir', config.backup_dir))) # use manager_db_dir if set, else backup_dir
536
- db_dir = expand_path(getattr(config, 'manager_db_dir', None) or config.backup_dir)
537
-
538
- # Which db files to inspect?
539
- backup_def = getattr(parsed_args, "backup_def", None)
540
- db_files = (
541
- [os.path.join( db_dir, f"{backup_def}.db")]
542
- if backup_def
543
- else [os.path.join( db_dir, f) for f in os.listdir( db_dir) if f.endswith(".db")]
544
- )
652
+ try:
653
+ from dar_backup.config_settings import ConfigSettings
654
+ import subprocess
655
+ import re
656
+ import os
657
+ from datetime import datetime
658
+
659
+ # Expand config path
660
+ config_file = get_config_file(parsed_args)
661
+ config = ConfigSettings(config_file=config_file)
662
+ #db_dir = expand_path((getattr(config, 'manager_db_dir', config.backup_dir))) # use manager_db_dir if set, else backup_dir
663
+ db_dir = expand_path(getattr(config, 'manager_db_dir', None) or config.backup_dir)
664
+
665
+ # Which db files to inspect?
666
+ backup_def = getattr(parsed_args, "backup_def", None)
667
+ db_files = (
668
+ [os.path.join( db_dir, f"{backup_def}.db")]
669
+ if backup_def
670
+ else [os.path.join( db_dir, f) for f in os.listdir( db_dir) if f.endswith(".db")]
671
+ )
545
672
 
546
- completions = []
673
+ completions = []
547
674
 
548
- for db_path in db_files:
549
- if not os.path.exists(db_path):
550
- continue
675
+ for db_path in db_files:
676
+ if not os.path.exists(db_path):
677
+ continue
551
678
 
552
- try:
553
- result = subprocess.run(
554
- ["dar_manager", "--base", db_path, "--list"],
555
- stdout=subprocess.PIPE,
556
- stderr=subprocess.DEVNULL,
557
- text=True,
558
- check=True
559
- )
560
- except subprocess.CalledProcessError:
561
- continue
679
+ try:
680
+ result = subprocess.run(
681
+ ["dar_manager", "--base", db_path, "--list"],
682
+ stdout=subprocess.PIPE,
683
+ stderr=subprocess.DEVNULL,
684
+ text=True,
685
+ check=True
686
+ )
687
+ except subprocess.CalledProcessError:
688
+ continue
562
689
 
563
- for line in result.stdout.splitlines():
564
- parts = line.strip().split("\t")
565
- if len(parts) >= 3:
566
- archive = parts[2].strip()
567
- if archive.startswith(prefix):
568
- completions.append(archive)
690
+ for line in result.stdout.splitlines():
691
+ parts = line.strip().split("\t")
692
+ if len(parts) >= 3:
693
+ archive = parts[2].strip()
694
+ if archive.startswith(prefix):
695
+ completions.append(archive)
569
696
 
570
- completions = sorted(set(completions), key=sort_key)
571
- return completions or ["[no matching archives]"]
697
+ completions = sorted(set(completions), key=sort_key)
698
+ return completions or ["[no matching archives]"]
699
+ except Exception:
700
+ completer_logger.exception("archive_content_completer failed")
701
+ return []
572
702
 
573
703
 
574
704
 
@@ -578,55 +708,59 @@ def add_specific_archive_completer(prefix, parsed_args, **kwargs):
578
708
  but not yet present in the <backup_def>.db catalog.
579
709
  If --backup-def is provided, restrict suggestions to that.
580
710
  """
581
- from dar_backup.config_settings import ConfigSettings
582
- import subprocess
583
- import re
584
- import os
585
- from datetime import datetime
586
-
587
- config_file = expand_path(getattr(parsed_args, "config_file", "~/.config/dar-backup/dar-backup.conf"))
588
- config = ConfigSettings(config_file=config_file)
589
- #db_dir = expand_path((getattr(config, 'manager_db_dir', config.backup_dir))) # use manager_db_dir if set, else backup_dir
590
- db_dir = expand_path(getattr(config, 'manager_db_dir') or config.backup_dir)
591
- backup_dir = config.backup_dir
592
- backup_def = getattr(parsed_args, "backup_def", None)
593
-
594
- # Match pattern for archive base names: e.g. test_FULL_2025-04-01
595
- dar_pattern = re.compile(r"^(.*?_(FULL|DIFF|INCR)_(\d{4}-\d{2}-\d{2}))\.1\.dar$")
596
-
597
- # Step 1: scan backup_dir for .1.dar files
598
- all_archives = set()
599
- for fname in os.listdir(backup_dir):
600
- match = dar_pattern.match(fname)
601
- if match:
602
- base = match.group(1)
603
- if base.startswith(prefix):
604
- if not backup_def or base.startswith(f"{backup_def}_"):
605
- all_archives.add(base)
606
-
607
- # Step 2: exclude ones already present in the .db
608
- db_path = os.path.join(db_dir, f"{backup_def}.db") if backup_def else None
609
- existing = set()
610
-
611
- if db_path and os.path.exists(db_path):
612
- try:
613
- result = subprocess.run(
614
- ["dar_manager", "--base", db_path, "--list"],
615
- stdout=subprocess.PIPE,
616
- stderr=subprocess.DEVNULL,
617
- text=True,
618
- check=True
619
- )
620
- for line in result.stdout.splitlines():
621
- parts = line.strip().split("\t")
622
- if len(parts) >= 3:
623
- existing.add(parts[2].strip())
624
- except subprocess.CalledProcessError:
625
- pass
626
-
627
- # Step 3: return filtered list
628
- candidates = sorted(archive for archive in all_archives if archive not in existing)
629
- return candidates or ["[no new archives]"]
711
+ try:
712
+ from dar_backup.config_settings import ConfigSettings
713
+ import subprocess
714
+ import re
715
+ import os
716
+ from datetime import datetime
717
+
718
+ config_file = get_config_file(parsed_args)
719
+ config = ConfigSettings(config_file=config_file)
720
+ #db_dir = expand_path((getattr(config, 'manager_db_dir', config.backup_dir))) # use manager_db_dir if set, else backup_dir
721
+ db_dir = expand_path(getattr(config, 'manager_db_dir') or config.backup_dir)
722
+ backup_dir = config.backup_dir
723
+ backup_def = getattr(parsed_args, "backup_def", None)
724
+
725
+ # Match pattern for archive base names: e.g. test_FULL_2025-04-01
726
+ dar_pattern = re.compile(r"^(.*?_(FULL|DIFF|INCR)_(\d{4}-\d{2}-\d{2}))\.1\.dar$")
727
+
728
+ # Step 1: scan backup_dir for .1.dar files
729
+ all_archives = set()
730
+ for fname in os.listdir(backup_dir):
731
+ match = dar_pattern.match(fname)
732
+ if match:
733
+ base = match.group(1)
734
+ if base.startswith(prefix):
735
+ if not backup_def or base.startswith(f"{backup_def}_"):
736
+ all_archives.add(base)
737
+
738
+ # Step 2: exclude ones already present in the .db
739
+ db_path = os.path.join(db_dir, f"{backup_def}.db") if backup_def else None
740
+ existing = set()
741
+
742
+ if db_path and os.path.exists(db_path):
743
+ try:
744
+ result = subprocess.run(
745
+ ["dar_manager", "--base", db_path, "--list"],
746
+ stdout=subprocess.PIPE,
747
+ stderr=subprocess.DEVNULL,
748
+ text=True,
749
+ check=True
750
+ )
751
+ for line in result.stdout.splitlines():
752
+ parts = line.strip().split("\t")
753
+ if len(parts) >= 3:
754
+ existing.add(parts[2].strip())
755
+ except subprocess.CalledProcessError:
756
+ pass
757
+
758
+ # Step 3: return filtered list
759
+ candidates = sorted(archive for archive in all_archives if archive not in existing)
760
+ return candidates or ["[no new archives]"]
761
+ except Exception:
762
+ completer_logger.exception("add_specific_archive_completer failed")
763
+ return []
630
764
 
631
765
 
632
766
 
@@ -735,8 +869,12 @@ def normalize_dir(path: str) -> str:
735
869
 
736
870
  # Reusable pattern for archive file naming
737
871
  archive_pattern = re.compile(
738
- r'^.+?_(FULL|DIFF|INCR)_(\d{4}-\d{2}-\d{2})\.\d+\.dar'
739
- r'(?:\.vol\d+(?:\+\d+)?\.par2|\.par2)?$'
872
+ r'^.+?_(FULL|DIFF|INCR)_(\d{4}-\d{2}-\d{2})'
873
+ r'(?:'
874
+ r'\.\d+\.dar(?:\.vol\d+(?:\+\d+)?\.par2|\.par2)?'
875
+ r'|(?:\.vol\d+(?:\+\d+)?\.par2|\.par2)'
876
+ r'|\.par2\.manifest\.ini'
877
+ r')$'
740
878
  )
741
879
 
742
880
  def is_safe_filename(filename: str) -> bool:
@@ -757,4 +895,119 @@ def is_safe_path(path: str) -> bool:
757
895
  and '..' not in normalized.split(os.sep)
758
896
  )
759
897
 
898
+ def get_config_file(args) -> str:
899
+ """
900
+ Returns the config file path based on the following precedence:
901
+ 1. Command-line argument (--config-file)
902
+ 2. Environment variable (DAR_BACKUP_CONFIG_FILE)
903
+ 3. Default path (~/.config/dar-backup/dar-backup.conf)
904
+ """
905
+ DEFAULT_CONFIG_FILE = "~/.config/dar-backup/dar-backup.conf"
906
+
907
+ env_cf = os.getenv("DAR_BACKUP_CONFIG_FILE")
908
+ env_cf = env_cf.strip() if env_cf else None
909
+
910
+ cli_cf = getattr(args, "config_file", None)
911
+ cli_cf = cli_cf.strip() if cli_cf else None
912
+
913
+ raw_config = (
914
+ cli_cf
915
+ or env_cf
916
+ or DEFAULT_CONFIG_FILE
917
+ )
918
+
919
+ config_settings_path = os.path.abspath(os.path.expanduser(os.path.expandvars(raw_config)))
920
+ return config_settings_path
921
+
922
+
923
+
924
+ def is_under_base_dir(candidate: Path, base_dir: Path) -> bool:
925
+ """
926
+ True iff candidate resolves under base_dir (symlink-safe).
927
+ """
928
+ try:
929
+ base = base_dir.resolve(strict=True)
930
+ resolved = candidate.resolve(strict=False)
931
+ except Exception:
932
+ return False
933
+ return resolved == base or base in resolved.parents
934
+
935
+
936
+ def safe_remove_file(path_str: str, *, base_dir: Path) -> bool:
937
+ """
938
+ Remove a file only if it:
939
+ - is under base_dir (after resolve),
940
+ - matches archive naming convention by BASENAME,
941
+ - is a regular file (not a dir),
942
+ - is not a symlink (optional hardening).
943
+ Returns True if removed.
944
+ """
945
+ p = Path(path_str)
946
+
947
+ # Enforce containment first (defeats ../ and symlink escape)
948
+ if not is_under_base_dir(p, base_dir):
949
+ logger.warning("Refusing to delete outside base_dir: %s (base=%s)", p, base_dir)
950
+ return False
951
+
952
+ # Validate filename shape on basename only
953
+ if not is_safe_filename(p.name):
954
+ logger.warning("Refusing to delete non-matching filename: %s", p.name)
955
+ return False
956
+
957
+ # Hardening: don't follow symlinks
958
+ if p.is_symlink():
959
+ logger.warning("Refusing to delete symlink: %s", p)
960
+ return False
961
+
962
+ # Only delete regular files
963
+ if not p.is_file():
964
+ logger.warning("Refusing to delete non-file: %s", p)
965
+ return False
966
+
967
+ p.unlink()
968
+ return True
969
+
970
+
971
+
972
+ # Allowed archive name:
973
+ # <definition>_(FULL|DIFF|INCR)_YYYY-MM-DD
974
+ # Example:
975
+ # pj-homedir_INCR_2025-11-22
976
+ _ARCHIVE_NAME_RE = re.compile(
977
+ r"^(?P<def>[A-Za-z0-9][A-Za-z0-9._-]{0,127})_"
978
+ r"(?P<kind>FULL|DIFF|INCR)_"
979
+ r"(?P<date>\d{4}-\d{2}-\d{2})$"
980
+ )
981
+
982
+ def is_archive_name_allowed(name: str) -> bool:
983
+ """
984
+ Return True iff the archive name is safe and valid.
985
+
986
+ Security properties:
987
+ - name only, never a path (no /, \\, or ..)
988
+ - strict allowed character set
989
+ - must be FULL / DIFF / INCR
990
+ - date must be a real calendar date
991
+ """
992
+ if not isinstance(name, str):
993
+ return False
994
+
995
+ name = name.strip()
996
+
997
+ # Reject anything path-like
998
+ if "/" in name or "\\" in name or ".." in name:
999
+ return False
1000
+
1001
+ m = _ARCHIVE_NAME_RE.match(name)
1002
+ if not m:
1003
+ return False
1004
+
1005
+ # Validate date is real (not just shape)
1006
+ try:
1007
+ date.fromisoformat(m.group("date")) # <-- FIX
1008
+ # alternatively:
1009
+ # datetime.strptime(m.group("date"), "%Y-%m-%d")
1010
+ except ValueError:
1011
+ return False
760
1012
 
1013
+ return True