dar-backup 1.0.0__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/Changelog.md +35 -1
- dar_backup/README.md +283 -22
- dar_backup/__about__.py +3 -1
- dar_backup/cleanup.py +153 -38
- dar_backup/command_runner.py +135 -102
- dar_backup/config_settings.py +143 -0
- dar_backup/dar-backup.conf +11 -0
- dar_backup/dar-backup.conf.j2 +42 -0
- dar_backup/dar_backup.py +391 -90
- dar_backup/manager.py +9 -3
- dar_backup/util.py +383 -130
- {dar_backup-1.0.0.dist-info → dar_backup-1.0.1.dist-info}/METADATA +285 -24
- dar_backup-1.0.1.dist-info/RECORD +25 -0
- {dar_backup-1.0.0.dist-info → dar_backup-1.0.1.dist-info}/WHEEL +1 -1
- dar_backup-1.0.0.dist-info/RECORD +0 -25
- {dar_backup-1.0.0.dist-info → dar_backup-1.0.1.dist-info}/entry_points.txt +0 -0
- {dar_backup-1.0.0.dist-info → dar_backup-1.0.1.dist-info}/licenses/LICENSE +0 -0
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=
|
|
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
|
-
|
|
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=
|
|
154
|
+
def _setup_completer_logger(logfile: str = None):
|
|
141
155
|
logger = logging.getLogger("completer")
|
|
142
156
|
if not logger.handlers:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
return []
|
|
581
|
+
if not os.path.isdir(backup_dir):
|
|
582
|
+
return []
|
|
473
583
|
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
480
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
492
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
673
|
+
completions = []
|
|
547
674
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
675
|
+
for db_path in db_files:
|
|
676
|
+
if not os.path.exists(db_path):
|
|
677
|
+
continue
|
|
551
678
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
571
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
if
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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})
|
|
739
|
-
r'(
|
|
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
|