dar-backup 1.0.2__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/__about__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "1.0.2"
1
+ __version__ = "1.1.0"
2
2
 
3
3
  __author__ = "Per Jensen"
4
4
 
dar_backup/cleanup.py CHANGED
@@ -16,10 +16,8 @@ This script removes old DIFF and INCR archives + accompanying .par2 files accord
16
16
 
17
17
  import argcomplete
18
18
  import argparse
19
- import logging
20
19
  import os
21
20
  import re
22
- import subprocess
23
21
  import sys
24
22
  sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
25
23
 
@@ -29,7 +27,7 @@ from inputimeout import inputimeout, TimeoutOccurred
29
27
  from pathlib import Path
30
28
  from sys import stderr
31
29
  from time import time
32
- from typing import Dict, List, NamedTuple, Tuple
30
+ from typing import List, NamedTuple, Tuple
33
31
  import glob
34
32
 
35
33
 
@@ -147,7 +145,7 @@ def delete_old_backups(backup_dir, age, backup_type, args, backup_definition=Non
147
145
  safe_remove_file(file_path, base_dir=Path(backup_dir))
148
146
  logger.info(f"Deleted {backup_type} backup: {file_path}")
149
147
  archive_name = filename.split('.')[0]
150
- if not archive_name in archives_deleted:
148
+ if archive_name not in archives_deleted:
151
149
  logger.debug(f"Archive name: '{archive_name}' added to catalog deletion list")
152
150
  archives_deleted[archive_name] = True
153
151
  except Exception as e:
@@ -209,7 +207,7 @@ def delete_catalog(catalog_name: str, args: NamedTuple) -> bool:
209
207
  """
210
208
  Call `manager.py` to delete the specified catalog in it's database
211
209
  """
212
- command = [f"manager", "--remove-specific-archive", catalog_name, "--config-file", args.config_file, '--log-level', 'debug', '--log-stdout']
210
+ command = ["manager", "--remove-specific-archive", catalog_name, "--config-file", args.config_file, '--log-level', 'debug', '--log-stdout']
213
211
  logger.info(f"Deleting catalog '{catalog_name}' using config file: '{args.config_file}'")
214
212
  try:
215
213
  result:CommandResult = runner.run(command)
@@ -366,7 +364,7 @@ def main():
366
364
  logger.error(f"Alternate archive directory does not exist: {args.alternate_archive_dir}, exiting")
367
365
  sys.exit(1)
368
366
  if not os.path.isdir(args.alternate_archive_dir):
369
- logger.error(f"Alternate archive directory is not a directory, exiting")
367
+ logger.error("Alternate archive directory is not a directory, exiting")
370
368
  sys.exit(1)
371
369
  config_settings.backup_dir = args.alternate_archive_dir
372
370
 
@@ -13,6 +13,7 @@ try:
13
13
  except ImportError:
14
14
  termios = None
15
15
  import tempfile
16
+ import time
16
17
  sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))
17
18
  from typing import List, Optional, Union
18
19
  from dar_backup.util import get_logger
@@ -89,12 +90,21 @@ class CommandRunner:
89
90
  ):
90
91
  self.logger = logger or get_logger()
91
92
  self.command_logger = command_logger or get_logger(command_output_logger=True)
93
+ if default_timeout is not None:
94
+ try:
95
+ default_timeout = int(default_timeout)
96
+ except (TypeError, ValueError):
97
+ default_timeout = 30
98
+ if not isinstance(default_timeout, int):
99
+ default_timeout = 30
92
100
  self.default_timeout = default_timeout
93
101
  self.default_capture_limit_bytes = default_capture_limit_bytes
94
102
 
95
103
  if not self.logger or not self.command_logger:
96
104
  self.logger_fallback()
97
105
 
106
+ if self.default_timeout is not None and self.default_timeout <= 0:
107
+ self.default_timeout = None
98
108
 
99
109
  def logger_fallback(self):
100
110
  """
@@ -138,7 +148,10 @@ class CommandRunner:
138
148
  stdin: Optional[int] = subprocess.DEVNULL
139
149
  ) -> CommandResult:
140
150
  self._text_mode = text
141
- timeout = timeout or self.default_timeout
151
+ if timeout is None:
152
+ timeout = self.default_timeout
153
+ if timeout is not None and timeout <= 0:
154
+ timeout = None
142
155
  if capture_output_limit_bytes is None:
143
156
  capture_output_limit_bytes = self.default_capture_limit_bytes
144
157
  if capture_output_limit_bytes is not None and capture_output_limit_bytes < 0:
@@ -199,6 +212,7 @@ class CommandRunner:
199
212
  truncated_stderr = {"value": False}
200
213
 
201
214
  try:
215
+ start_time = time.monotonic()
202
216
  use_pipes = capture_output or log_output
203
217
  process = subprocess.Popen(
204
218
  cmd,
@@ -209,6 +223,18 @@ class CommandRunner:
209
223
  bufsize=-1,
210
224
  cwd=cwd
211
225
  )
226
+ pid = getattr(process, "pid", None)
227
+ if log_output:
228
+ self.command_logger.debug(
229
+ "Process started pid=%s cwd=%s",
230
+ pid if pid is not None else "unknown",
231
+ cwd or os.getcwd(),
232
+ )
233
+ self.logger.debug(
234
+ "Process started pid=%s cwd=%s",
235
+ pid if pid is not None else "unknown",
236
+ cwd or os.getcwd(),
237
+ )
212
238
  except Exception as e:
213
239
  stack = traceback.format_exc()
214
240
  return CommandResult(
@@ -287,7 +313,12 @@ class CommandRunner:
287
313
  process.wait(timeout=timeout)
288
314
  except subprocess.TimeoutExpired:
289
315
  process.kill()
290
- log_msg = f"Command timed out after {timeout} seconds: {' '.join(cmd)}:\n"
316
+ duration = time.monotonic() - start_time
317
+ pid = getattr(process, "pid", None)
318
+ log_msg = (
319
+ f"Command timed out after {timeout} seconds: {' '.join(cmd)} "
320
+ f"(pid={pid if pid is not None else 'unknown'}, elapsed={duration:.2f}s):\n"
321
+ )
291
322
  self.logger.error(log_msg)
292
323
  return CommandResult(-1, ''.join(stdout_lines), log_msg.join(stderr_lines))
293
324
  except Exception as e:
@@ -298,6 +329,21 @@ class CommandRunner:
298
329
 
299
330
  for t in threads:
300
331
  t.join()
332
+ duration = time.monotonic() - start_time
333
+ pid = getattr(process, "pid", None)
334
+ if log_output:
335
+ self.command_logger.debug(
336
+ "Process finished pid=%s returncode=%s elapsed=%.2fs",
337
+ pid if pid is not None else "unknown",
338
+ process.returncode,
339
+ duration,
340
+ )
341
+ self.logger.debug(
342
+ "Process finished pid=%s returncode=%s elapsed=%.2fs",
343
+ pid if pid is not None else "unknown",
344
+ process.returncode,
345
+ duration,
346
+ )
301
347
 
302
348
  if self._text_mode:
303
349
  stdout_combined = ''.join(stdout_lines)
@@ -4,7 +4,6 @@ import configparser
4
4
  import re
5
5
  from dataclasses import dataclass, field, fields
6
6
  from os.path import expandvars, expanduser
7
- from pathlib import Path
8
7
  from typing import Optional, Pattern
9
8
 
10
9
  from dar_backup.exceptions import ConfigSettingsError
dar_backup/dar_backup.py CHANGED
@@ -27,12 +27,10 @@ import xml.etree.ElementTree as ET
27
27
  import tempfile
28
28
  import threading
29
29
 
30
- from argparse import ArgumentParser
31
30
  from datetime import datetime
32
31
  from pathlib import Path
33
32
  from sys import exit
34
33
  from sys import stderr
35
- from sys import argv
36
34
  from sys import version_info
37
35
  from time import time
38
36
  from rich.console import Console
@@ -43,6 +41,7 @@ from . import __about__ as about
43
41
  from dar_backup.config_settings import ConfigSettings
44
42
  from dar_backup.util import list_backups
45
43
  from dar_backup.util import setup_logging
44
+ from dar_backup.util import derive_trace_log_path
46
45
  from dar_backup.util import get_logger
47
46
  from dar_backup.util import BackupError
48
47
  from dar_backup.util import RestoreError
@@ -54,14 +53,11 @@ from dar_backup.util import get_binary_info
54
53
  from dar_backup.util import print_aligned_settings
55
54
  from dar_backup.util import backup_definition_completer, list_archive_completer
56
55
  from dar_backup.util import show_scriptname
57
- from dar_backup.util import print_debug
58
56
  from dar_backup.util import send_discord_message
59
57
 
60
58
  from dar_backup.command_runner import CommandRunner
61
- from dar_backup.command_runner import CommandResult
62
59
 
63
60
 
64
- from argcomplete.completers import FilesCompleter
65
61
 
66
62
  logger = None
67
63
  runner = None
@@ -126,7 +122,7 @@ def generic_backup(type: str, command: List[str], backup_file: str, backup_defin
126
122
  logger.error(f"Backup command failed: {e}")
127
123
  raise BackupError(f"Backup command failed: {e}") from e
128
124
  except Exception as e:
129
- logger.exception(f"Unexpected error during backup")
125
+ logger.exception("Unexpected error during backup")
130
126
  raise BackupError(f"Unexpected error during backup: {e}") from e
131
127
 
132
128
 
@@ -164,12 +160,37 @@ def find_files_with_paths(xml_doc: str):
164
160
  return files_list
165
161
 
166
162
 
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
+
167
187
  def iter_files_with_paths_from_xml(xml_path: str) -> Iterator[Tuple[str, str]]:
168
188
  """
169
189
  Stream file paths and sizes from a DAR XML listing to keep memory usage low.
170
190
  """
171
191
  path_stack: List[str] = []
172
- context = ET.iterparse(xml_path, events=("start", "end"))
192
+ # Disable XXE by stripping DOCTYPE
193
+ context = ET.iterparse(DoctypeStripper(xml_path), events=("start", "end"))
173
194
  for event, elem in context:
174
195
  if event == "start" and elem.tag == "Directory":
175
196
  dir_name = elem.get("name")
@@ -433,7 +454,7 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
433
454
  logger.error(f"Failure: file '{restored_file_path}' did not match the original")
434
455
  except PermissionError:
435
456
  result = False
436
- logger.exception(f"Permission error while comparing files, continuing....")
457
+ logger.exception("Permission error while comparing files, continuing....")
437
458
  logger.error("Exception details:", exc_info=True)
438
459
  except FileNotFoundError as exc:
439
460
  result = False
@@ -1118,7 +1139,8 @@ def generate_par2_files(backup_file: str, config_settings: ConfigSettings, args,
1118
1139
  def filter_darrc_file(darrc_path):
1119
1140
  """
1120
1141
  Filters the .darrc file to remove lines containing the options: -vt, -vs, -vd, -vf, and -va.
1121
- 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).
1122
1144
  The file permissions are set to 440.
1123
1145
 
1124
1146
  Params:
@@ -1133,29 +1155,36 @@ def filter_darrc_file(darrc_path):
1133
1155
  # Define options to filter out
1134
1156
  options_to_remove = {"-vt", "-vs", "-vd", "-vf", "-va"}
1135
1157
 
1136
- # Get the user's home directory
1137
- 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
1138
1164
 
1139
- # Create a unique file name in the home directory
1140
- filtered_darrc_path = os.path.join(home_dir, f"filtered_darrc_{next(tempfile._get_candidate_names())}.darrc")
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)
1141
1176
 
1142
- try:
1143
- with open(darrc_path, "r") as infile, open(filtered_darrc_path, "w") as outfile:
1144
- for line in infile:
1145
- # Check if any unwanted option is in the line
1146
- if not any(option in line for option in options_to_remove):
1147
- outfile.write(line)
1148
-
1149
- # Set file permissions to 440 (read-only for owner and group, no permissions for others)
1150
- os.chmod(filtered_darrc_path, 0o440)
1177
+ # Set file permissions to 440 (read-only for owner and group, no permissions for others)
1178
+ os.chmod(filtered_darrc_path, 0o440)
1151
1179
 
1152
- return filtered_darrc_path
1180
+ return filtered_darrc_path
1153
1181
 
1154
- except Exception as e:
1155
- # If anything goes wrong, clean up the temp file if it was created
1156
- if os.path.exists(filtered_darrc_path):
1157
- os.remove(filtered_darrc_path)
1158
- raise RuntimeError(f"Error filtering .darrc file: {e}")
1182
+ except Exception as e:
1183
+ last_error = e
1184
+ if os.path.exists(filtered_darrc_path):
1185
+ os.remove(filtered_darrc_path)
1186
+
1187
+ raise RuntimeError(f"Error filtering .darrc file: {last_error}")
1159
1188
 
1160
1189
 
1161
1190
 
@@ -1257,16 +1286,35 @@ def print_markdown(source: str, from_string: bool = False, pretty: bool = True):
1257
1286
 
1258
1287
 
1259
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
+
1260
1310
  def print_changelog(path: str = None, pretty: bool = True):
1261
- if path is None:
1262
- path = Path(__file__).parent / "Changelog.md"
1263
- print_markdown(str(path), pretty=pretty)
1311
+ resolved_path = _resolve_doc_path(path, "Changelog.md")
1312
+ print_markdown(str(resolved_path), pretty=pretty)
1264
1313
 
1265
1314
 
1266
1315
  def print_readme(path: str = None, pretty: bool = True):
1267
- if path is None:
1268
- path = Path(__file__).parent / "README.md"
1269
- print_markdown(str(path), pretty=pretty)
1316
+ resolved_path = _resolve_doc_path(path, "README.md")
1317
+ print_markdown(str(resolved_path), pretty=pretty)
1270
1318
 
1271
1319
  def list_definitions(backup_d_dir: str) -> List[str]:
1272
1320
  """
@@ -1454,6 +1502,7 @@ def main():
1454
1502
  if command_output_log == config_settings.logfile_location:
1455
1503
  print(f"Error: logfile_location in {args.config_file} does not end at 'dar-backup.log', exiting", file=stderr)
1456
1504
 
1505
+ trace_log_file = derive_trace_log_path(config_settings.logfile_location)
1457
1506
  logger = setup_logging(
1458
1507
  config_settings.logfile_location,
1459
1508
  command_output_log,
@@ -1461,6 +1510,7 @@ def main():
1461
1510
  args.log_stdout,
1462
1511
  logfile_max_bytes=config_settings.logfile_max_bytes,
1463
1512
  logfile_backup_count=config_settings.logfile_backup_count,
1513
+ trace_log_file=trace_log_file,
1464
1514
  trace_log_max_bytes=getattr(config_settings, "trace_log_max_bytes", 10485760),
1465
1515
  trace_log_backup_count=getattr(config_settings, "trace_log_backup_count", 1)
1466
1516
  )
@@ -1474,6 +1524,8 @@ def main():
1474
1524
  clean_restore_test_directory(config_settings)
1475
1525
 
1476
1526
 
1527
+ filtered_darrc_path = None
1528
+
1477
1529
  try:
1478
1530
  if not args.darrc:
1479
1531
  current_script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -1488,7 +1540,8 @@ def main():
1488
1540
 
1489
1541
  if args.suppress_dar_msg:
1490
1542
  logger.info("Suppressing dar messages, do not use options: -vt, -vs, -vd, -vf, -va")
1491
- args.darrc = filter_darrc_file(args.darrc)
1543
+ filtered_darrc_path = filter_darrc_file(args.darrc)
1544
+ args.darrc = filtered_darrc_path
1492
1545
  logger.debug(f"Filtered .darrc file: {args.darrc}")
1493
1546
 
1494
1547
  start_msgs: List[Tuple[str, str]] = []
@@ -1522,6 +1575,7 @@ def main():
1522
1575
  args.verbose and start_msgs.append(("Restore dir:", restore_dir))
1523
1576
 
1524
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))
1525
1579
  args.verbose and start_msgs.append(("Logfile max size (bytes):", config_settings.logfile_max_bytes))
1526
1580
  args.verbose and start_msgs.append(("Logfile backup count:", config_settings.logfile_backup_count))
1527
1581
 
@@ -1611,10 +1665,9 @@ def main():
1611
1665
  end_time=int(time())
1612
1666
  logger.info(f"END TIME: {end_time}")
1613
1667
  # Clean up
1614
- if os.path.exists(args.darrc) and (os.path.dirname(args.darrc) == os.path.expanduser("~")):
1615
- if os.path.basename(args.darrc).startswith("filtered_darrc_"):
1616
- if os.remove(args.darrc):
1617
- 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}")
1618
1671
 
1619
1672
 
1620
1673
  # Determine exit code
@@ -100,7 +100,7 @@ def write_unit_files(venv, dar_path, install=False):
100
100
 
101
101
  write_unit_file(output_path, "dar-cleanup.service", generate_cleanup_service(venv, dar_path))
102
102
  write_unit_file(output_path, "dar-cleanup.timer", CLEANUP_TIMER)
103
- print(f" → Fires on: *-*-* 21:07:00")
103
+ print(" → Fires on: *-*-* 21:07:00")
104
104
 
105
105
  if install:
106
106
  for mode in FLAGS:
dar_backup/demo.py CHANGED
@@ -19,7 +19,6 @@ User can set ROOT_DIR, DIR_TO_BACKUP and BACKUP_DIR (destination for backups) vi
19
19
 
20
20
  import argparse
21
21
  import os
22
- import shutil
23
22
  import sys
24
23
 
25
24
  from . import __about__ as about
@@ -201,7 +200,7 @@ def main():
201
200
  Path(vars_map["TEST_RESTORE_DIR"]).mkdir(parents=True, exist_ok=True)
202
201
  Path(vars_map["CONFIG_DIR"]).mkdir(parents=True, exist_ok=True)
203
202
  Path(vars_map["BACKUP_D_DIR"]).mkdir(parents=True, exist_ok=True)
204
- print(f"Directories created.")
203
+ print("Directories created.")
205
204
 
206
205
  generate_file(args, "demo_backup_def.j2", Path(vars_map["BACKUP_D_DIR"]).joinpath("demo"), vars_map, opts_dict)
207
206
  generate_file(args, "dar-backup.conf.j2", Path(vars_map["CONFIG_DIR"]).joinpath("dar-backup.conf"), vars_map, opts_dict)