Skip to content

Commit 25bf607

Browse files
authored
Merge pull request #37 from Cyber-Syntax:refactor/cleanup
refactor: codebase and enhance cleanup processes
2 parents e872003 + ebcce36 commit 25bf607

16 files changed

Lines changed: 351 additions & 191 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,5 @@ source .venv/bin/activate
2020
2. Run pytest with following command to ensure all tests pass:
2121

2222
```bash
23-
pytest -v -qa --strict-markers
23+
pytest -v -q --strict-markers
2424
```

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Changelog
22
All notable changes to this project will be documented in this file. Commits automatically generated by github actions.
33

4+
## v0.6.2-beta
45
## v0.6.1-beta
56
## v0.6.0-beta
67
### BREAKING CHANGES

autotarcompress/commands/backup.py

Lines changed: 59 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Backup command implementation for creating compressed archives.
22
3-
This module contains the BackupCommand class that handles the creation of backup archives
4-
using tar and xz compression.
3+
This module contains the BackupCommand class that handles the creation of
4+
backup archives using tar and xz compression.
55
"""
66

77
import datetime
@@ -41,15 +41,15 @@ def execute(self) -> bool:
4141
4242
"""
4343
if not self.config.dirs_to_backup:
44-
msg = "No directories configured for backup. Skipping backup."
45-
print(msg)
46-
self.logger.error("No directories configured for backup. Skipping backup.")
44+
self._print_and_log(
45+
"No directories configured for backup. Skipping backup.", level="error"
46+
)
4747
return False
4848
total_size: int = self._calculate_total_size()
4949
if total_size == 0:
50-
msg = "Total backup size is 0 bytes. Nothing to back up."
51-
print(msg)
52-
self.logger.warning("Total backup size is 0 bytes. Nothing to back up.")
50+
self._print_and_log(
51+
"Total backup size is 0 bytes. Nothing to back up.", level="warning"
52+
)
5353
return False
5454
success: bool = self._run_backup_process(total_size)
5555
if success:
@@ -77,56 +77,64 @@ def _run_backup_process(self, total_size: int) -> bool:
7777
7878
"""
7979
if os.path.exists(self.config.backup_path):
80-
print(f"File already exists: {self.config.backup_path}")
81-
self.logger.warning("Backup file already exists: %s", self.config.backup_path)
82-
if input("Do you want to remove it? (y/n): ").lower() == "y":
83-
try:
84-
os.remove(self.config.backup_path)
85-
self.logger.info("Removed existing backup file: %s", self.config.backup_path)
86-
except Exception as e:
87-
print(f"Failed to remove existing backup: {e}")
88-
self.logger.error("Failed to remove existing backup: %s", e)
89-
return False
90-
else:
91-
print("Backup aborted by user.")
92-
self.logger.info("Backup aborted by user due to existing file.")
80+
self._print_and_log(f"File already exists: {self.config.backup_path}", level="warning")
81+
if not self._prompt_overwrite():
82+
self._print_and_log("Backup aborted by user due to existing file.", level="info")
83+
return False
84+
try:
85+
os.remove(self.config.backup_path)
86+
self.logger.info("Removed existing backup file: %s", self.config.backup_path)
87+
# NOTE: Broad except is used here to ensure any file removal error
88+
# is caught during backup overwrite prompt. This is a critical IO
89+
# operation.
90+
except (OSError, PermissionError) as e:
91+
self._print_and_log(f"Failed to remove existing backup: {e}", level="error")
9392
return False
9493

95-
exclude_options = " ".join(f"--exclude={path}" for path in self.config.ignore_list)
94+
cmd = self._build_tar_command()
95+
total_size_gb = total_size / 1024**3
9696

97-
dir_paths = [os.path.expanduser(path) for path in self.config.dirs_to_backup]
97+
self.logger.info("Starting backup to %s", self.config.backup_path)
98+
self.logger.info("Total size: %.2f GB", total_size_gb)
9899

99-
# Properly quote directory paths to handle spaces and special characters
100-
quoted_paths = [shlex.quote(path) for path in dir_paths]
100+
try:
101+
subprocess.run(cmd, shell=True, check=True)
102+
self.logger.info("Backup completed successfully")
103+
self._print_and_log("Backup completed successfully.", level="info")
104+
return True
105+
except subprocess.CalledProcessError as e:
106+
self._print_and_log(f"Backup failed: {e}", level="error")
107+
return False
101108

102-
# Get CPU count safely
109+
def _build_tar_command(self) -> str:
110+
"""Build the tar+xz command string for backup."""
111+
exclude_options = " ".join(f"--exclude={path}" for path in self.config.ignore_list)
112+
dir_paths = [os.path.expanduser(path) for path in self.config.dirs_to_backup]
113+
quoted_paths = [shlex.quote(path) for path in dir_paths]
103114
cpu_count = os.cpu_count() or 1
104115
threads = max(1, cpu_count - 1)
105-
106-
# HACK: h option is used to follow symlinks
107-
cmd = (
116+
return (
108117
f"tar -chf - --one-file-system {exclude_options} "
109118
f"{' '.join(quoted_paths)} | "
110119
f"xz --threads={threads} > {self.config.backup_path}"
111120
)
112-
total_size_gb = total_size / 1024**3
113-
114-
self.logger.info(f"Starting backup to {self.config.backup_path}")
115-
self.logger.info(f"Total size: {total_size_gb:.2f} GB")
116121

117-
try:
118-
# FIX: later spinner not working for now
119-
# FAILED: not work as expected because of
120-
# "| tar: Removing leading `/' from member names" outputs
121-
# self._show_spinner(subprocess.Popen(cmd, shell=True))
122-
subprocess.run(cmd, shell=True, check=True)
123-
self.logger.info("Backup completed successfully")
124-
print("Backup completed successfully.")
125-
return True
126-
except subprocess.CalledProcessError as e:
127-
print(f"Backup failed: {e}")
128-
self.logger.error(f"Backup failed: {e}")
129-
return False
122+
def _prompt_overwrite(self) -> bool:
123+
"""Prompt user to overwrite existing backup file."""
124+
response = input("Do you want to remove it? (y/n): ").strip().lower()
125+
return response == "y"
126+
127+
def _print_and_log(self, message: str, level: str = "info") -> None:
128+
"""Print and log a message at the specified level."""
129+
print(message)
130+
if level == "info":
131+
self.logger.info(message)
132+
elif level == "warning":
133+
self.logger.warning(message)
134+
elif level == "error":
135+
self.logger.error(message)
136+
else:
137+
self.logger.debug(message)
130138

131139
def _save_backup_info(self, total_size: int) -> None:
132140
"""Save backup information to last-backup-info.json."""
@@ -146,10 +154,12 @@ def _save_backup_info(self, total_size: int) -> None:
146154
with open(info_file_path, "w", encoding="utf-8") as f:
147155
json.dump(backup_info, f, indent=2)
148156

149-
self.logger.info(f"Backup info saved to {info_file_path}")
157+
self.logger.info("Backup info saved to %s", info_file_path)
150158

151-
except Exception as e:
152-
self.logger.error(f"Failed to save backup info: {e}")
159+
# NOTE: Broad except is used here to ensure any error during backup
160+
# info saving is logged, as this is a non-critical reporting step.
161+
except (OSError, PermissionError, ValueError) as e:
162+
self.logger.error("Failed to save backup info: %s", e)
153163

154164
def _format_size(self, size_bytes: int) -> str:
155165
"""Format size in bytes to human readable format."""

autotarcompress/commands/cleanup.py

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import datetime
88
import logging
99
import os
10+
import shutil
1011
from pathlib import Path
1112

1213
from autotarcompress.commands.command import Command
@@ -20,49 +21,78 @@ def __init__(self, config: BackupConfig) -> None:
2021
"""Initialize CleanupCommand.
2122
2223
Args:
23-
config (BackupConfig): Backup configuration with retention and folder settings.
24+
config (BackupConfig): Backup configuration with retention and
25+
folder settings.
2426
2527
"""
2628
self.config: BackupConfig = config
2729
self.logger: logging.Logger = logging.getLogger(__name__)
2830

2931
def execute(self) -> bool:
30-
"""Delete old backup, encrypted, and decrypted files per retention policy.
32+
"""Delete old backup, encrypted, and decrypted files.
33+
34+
Per retention policy.
3135
3236
Returns:
33-
bool: Always True (cleanup always completes, even if nothing to delete).
37+
bool: Always True (cleanup always completes, even if nothing
38+
to delete).
3439
3540
"""
3641
self._cleanup_files(".tar.xz", self.config.keep_backup)
37-
self._cleanup_files(".tar.xz.enc", self.config.keep_enc_backup)
3842
self._cleanup_files(".tar.xz-decrypted", self.config.keep_backup)
43+
self._cleanup_files(".tar-extracted", self.config.keep_backup)
44+
self._cleanup_files(".tar.xz.enc", self.config.keep_enc_backup)
3945
return True
4046

4147
def _cleanup_files(self, ext: str, keep_count: int) -> None:
42-
"""Delete old files by extension, keeping only the most recent as configured.
48+
"""Delete old files by extension.
49+
50+
Keeping only the most recent as configured.
4351
4452
Args:
4553
ext (str): File extension to filter for cleanup.
4654
keep_count (int): Number of recent files to keep.
4755
4856
"""
57+
58+
def _extract_date_from_filename(filename: str) -> datetime.datetime:
59+
"""Extract datetime from filename for sorting.
60+
61+
Args:
62+
filename (str): Filename with date format at start.
63+
64+
Returns:
65+
datetime.datetime: Parsed datetime object.
66+
67+
"""
68+
return datetime.datetime.strptime(filename.split(".")[0], "%d-%m-%Y")
69+
4970
backup_folder: Path = Path(self.config.backup_folder)
5071
files: list[str] = sorted(
5172
[f for f in os.listdir(backup_folder) if f.endswith(ext)],
52-
key=lambda x: datetime.datetime.strptime(x.split(".")[0], "%d-%m-%Y"),
73+
key=_extract_date_from_filename,
5374
)
75+
5476
files_to_delete: list[str] = files if keep_count == 0 else files[:-keep_count]
5577
if not files_to_delete:
5678
msg = f"No old '{ext}' files to remove."
5779
print(msg)
5880
self.logger.info("No old '%s' files to remove.", ext)
59-
return
81+
return None
82+
6083
for old_file in files_to_delete:
6184
file_path = backup_folder / old_file
6285
try:
63-
file_path.unlink()
64-
self.logger.info("Deleted old backup: %s", old_file)
65-
print(f"Deleted old backup: {old_file}")
66-
except Exception as e:
86+
if file_path.is_dir():
87+
# Remove directory (recursively if not empty)
88+
shutil.rmtree(file_path)
89+
self.logger.info("Deleted old backup directory: %s", old_file)
90+
print(f"Deleted old backup directory: {old_file}")
91+
else:
92+
file_path.unlink()
93+
self.logger.info("Deleted old backup: %s", old_file)
94+
print(f"Deleted old backup: {old_file}")
95+
except (OSError, PermissionError) as e:
6796
self.logger.error("Failed to delete %s: %s", old_file, e)
6897
print(f"Failed to delete {old_file}: {e}")
98+
return None

autotarcompress/commands/decrypt.py

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@
1616

1717

1818
class DecryptCommand(Command):
19-
"""
20-
Command to securely decrypt backup archives using OpenSSL with PBKDF2.
19+
"""Command to securely decrypt backup archives using OpenSSL with PBKDF2.
2120
2221
This class ensures decryption parameters match those used for encryption and
2322
verifies file integrity post-decryption. Logging uses %s formatting for performance.
@@ -26,12 +25,12 @@ class DecryptCommand(Command):
2625
PBKDF2_ITERATIONS: int = 600000 # Must match encryption iterations
2726

2827
def __init__(self, config: BackupConfig, file_path: str) -> None:
29-
"""
30-
Initialize the DecryptCommand.
28+
"""Initialize the DecryptCommand.
3129
3230
Args:
3331
config (BackupConfig): The backup configuration object.
3432
file_path (str): Path to the encrypted file to decrypt.
33+
3534
"""
3635
self.config: BackupConfig = config
3736
self.file_path: str = file_path
@@ -40,18 +39,18 @@ def __init__(self, config: BackupConfig, file_path: str) -> None:
4039
self._safe_cleanup = ContextManager()._safe_cleanup
4140

4241
def execute(self) -> bool:
43-
"""
44-
Perform secure decryption with matched PBKDF2 parameters.
42+
"""Perform secure decryption with matched PBKDF2 parameters.
4543
4644
Returns:
4745
bool: True if decryption and integrity check succeed, False otherwise.
46+
4847
"""
4948
output_path: str = os.path.splitext(self.file_path)[0]
5049
decrypted_path: str = f"{output_path}-decrypted"
5150

5251
# Use password context manager to securely obtain password
53-
with self._password_context() as password:
54-
if not password:
52+
with self._password_context() as password: # type: ignore[var-annotated]
53+
if password is None:
5554
return False
5655

5756
cmd: list[str] = [
@@ -81,12 +80,11 @@ def execute(self) -> bool:
8180
timeout=300,
8281
shell=False,
8382
)
84-
self._verify_integrity(decrypted_path)
85-
return True
86-
except subprocess.CalledProcessError as e:
83+
except subprocess.CalledProcessError as exc:
8784
# Log sanitized error output to avoid leaking sensitive data
8885
self.logger.error(
89-
"Decryption failed: %s", self._sanitize_logs(e.stderr)
86+
"Decryption failed: %s",
87+
self._sanitize_logs(exc.stderr),
9088
)
9189
self._safe_cleanup(decrypted_path)
9290
return False
@@ -95,12 +93,15 @@ def execute(self) -> bool:
9593
self._safe_cleanup(decrypted_path)
9694
return False
9795

96+
self._verify_integrity(decrypted_path)
97+
return True
98+
9899
def _verify_integrity(self, decrypted_path: str) -> None:
99-
"""
100-
Verify decrypted file matches original backup checksum.
100+
"""Verify decrypted file matches original backup checksum.
101101
102102
Args:
103103
decrypted_path (str): Path to the decrypted file.
104+
104105
"""
105106
original_path: str = os.path.splitext(self.file_path)[0]
106107
if os.path.exists(original_path):
@@ -117,35 +118,39 @@ def _verify_integrity(self, decrypted_path: str) -> None:
117118
self.logger.error("Integrity check failed")
118119

119120
def _calculate_sha256(self, file_path: str) -> str:
120-
"""
121-
Calculate SHA256 checksum for a file.
121+
"""Calculate SHA256 checksum for a file.
122122
123123
Args:
124124
file_path (str): Path to the file.
125125
126126
Returns:
127127
str: SHA256 hex digest of the file contents.
128+
128129
"""
129130
sha256 = hashlib.sha256()
130-
with open(file_path, "rb") as f:
131+
with open(file_path, "rb") as file_obj:
131132
while True:
132-
data = f.read(65536)
133+
data = file_obj.read(65536)
133134
if not data:
134135
break
135136
sha256.update(data)
136137
return sha256.hexdigest()
137138

138139
def _sanitize_logs(self, output: bytes) -> str:
139-
"""
140-
Sanitize log output to redact sensitive information.
140+
"""Sanitize log output to redact sensitive information.
141141
142142
Args:
143143
output (bytes): Raw stderr output from subprocess.
144144
145145
Returns:
146146
str: Sanitized string safe for logging.
147+
147148
"""
148149
# Redact password and IP addresses from logs for security
149150
sanitized = re.sub(rb"password=[^\s]*", b"password=[REDACTED]", output)
150-
sanitized = re.sub(rb"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", b"[IP_REDACTED]", sanitized)
151+
sanitized = re.sub(
152+
rb"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b",
153+
b"[IP_REDACTED]",
154+
sanitized,
155+
)
151156
return sanitized.decode("utf-8", errors="replace")

0 commit comments

Comments
 (0)