Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ import pytest
from unittest.mock import MagicMock, patch

from autotarcompress.backup_manager import BackupManager
from autotarcompress.utils.hash_utils import verify_hash
from autotarcompress.utils import verify_hash


class TestBackupManager:
Expand Down
68 changes: 50 additions & 18 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,43 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [0.9.0-alpha] - 2026-08-23

### BREAKING CHANGES

- Switch encryption format to streaming AES-GCM with per-chunk nonces; existing data from previous versions cannot be decrypted

### Added

- Implement safe backup write by writing to a temporary file and atomically
replacing the final archive to avoid TOCTOU issues and partial files.
- Introduce a cross-process lock via a .lock file and atomic write of
metadata.json to prevent corruption during concurrent updates.

### Changed

- Migrated to src layout from flat layout.
- Updated dependencies:

```
Updated annotated-doc v0.0.4 -> v0.0.5
Updated cffi v2.0.0 -> v2.1.1
Removed click v8.3.3
Updated coverage v7.13.5 -> v7.15.4
Updated cryptography v47.0.0 -> v50.0.0
Updated hypothesis v6.152.4 -> v6.165.10
Updated markdown-it-py v4.0.0 -> v4.2.0
Updated packaging v26.2 -> v26.3
Updated pygments v2.20.0 -> v2.21.0
Updated pytest v9.0.3 -> v9.1.1
Updated ruff v0.15.12 -> v0.16.4
Updated typer v0.25.1 -> v0.27.1
Updated typing-extensions v4.15.0 -> v4.16.0
```

### Fixed

- Skip unrecognised filenames when pruning

## [0.8.1-alpha] - 2026-05-04

Expand All @@ -27,12 +59,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- **SHA256 Integrity Verification:** Added comprehensive file integrity verification system
- Calculate and store SHA256 hash of backup archives (.tar.zst)
- Calculate and store SHA256 hash of encrypted files (.enc)
- Calculate and store SHA256 hash of decrypted files
- Verify decrypted file integrity against original backup archive hash
- All hashes stored in metadata.json v2.0 with appropriate naming
- Added 28 new tests for hash utilities and metadata v2.0
- Calculate and store SHA256 hash of backup archives (.tar.zst)
- Calculate and store SHA256 hash of encrypted files (.enc)
- Calculate and store SHA256 hash of decrypted files
- Verify decrypted file integrity against original backup archive hash
- All hashes stored in metadata.json v2.0 with appropriate naming
- Added 28 new tests for hash utilities and metadata v2.0
- Pure Python encryption using the `cryptography` library - no more external OpenSSL binary dependency
- AES-256-GCM authenticated encryption provides both confidentiality and integrity verification
- PBKDF2-HMAC-SHA256 key derivation with 600,000 iterations (OWASP recommended)
Expand Down Expand Up @@ -61,17 +93,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Simplified zsh completion installation for better portability
- Added installation options for uv tool including development mode and legacy removal
- Upgraded dependencies:
- Updated click v8.3.1 -> v8.3.3
- Updated coverage v7.13.4 -> v7.13.5
- Updated cryptography v46.0.5 -> v47.0.0
- Updated hypothesis v6.151.6 -> v6.152.4
- Updated packaging v26.0 -> v26.2
- Updated pygments v2.19.2 -> v2.20.0
- Updated pytest v9.0.2 -> v9.0.3
- Updated pytest-cov v7.0.0 -> v7.1.0
- Updated rich v14.3.2 -> v15.0.0
- Updated ruff v0.15.1 -> v0.15.12
- Updated typer v0.23.0 -> v0.25.1
- Updated click v8.3.1 -> v8.3.3
- Updated coverage v7.13.4 -> v7.13.5
- Updated cryptography v46.0.5 -> v47.0.0
- Updated hypothesis v6.151.6 -> v6.152.4
- Updated packaging v26.0 -> v26.2
- Updated pygments v2.19.2 -> v2.20.0
- Updated pytest v9.0.2 -> v9.0.3
- Updated pytest-cov v7.0.0 -> v7.1.0
- Updated rich v14.3.2 -> v15.0.0
- Updated ruff v0.15.1 -> v0.15.12
- Updated typer v0.23.0 -> v0.25.1

### Removed

Expand Down
14 changes: 10 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = 'AutoTarCompress'
version = '0.8.1-alpha'
version = '0.9.0-alpha'
license = { text = "GPL-3.0-or-later" }
description = 'The script compresses specific directories into tar files, and it is able to encrypt them using the OpenSSL Python library. It also allows for decryption and extraction of the created files.'
keywords = ["tar", "compress", "backup"]
Expand Down Expand Up @@ -66,7 +66,13 @@ AutoTarCompress_LOG_DIR = { value = "/tmp/pytest-of-{USER}-logs/autotarcompress"
[tool.ruff]
line-length = 79
indent-width = 4
include = ["pyproject.toml", "src/**/*.py", "scripts/**/*.py", "autotarcompress/**/*.py", "tests/**/*.py"]
include = [
"pyproject.toml",
"src/**/*.py",
"scripts/**/*.py",
"autotarcompress/**/*.py",
"tests/**/*.py",
]

[tool.ruff.format]
# Like Black, use double quotes for strings.
Expand Down Expand Up @@ -145,8 +151,8 @@ lines-between-types = 0

[tool.ruff.lint.per-file-ignores]
"scripts/*.py" = [
"INP001", # Scripts are standalone, not part of a package
"T201", # print() is expected in CLI scripts
"INP001", # Scripts are standalone, not part of a package
"T201", # print() is expected in CLI scripts
]
"test_*.py" = [
"S101", # asserts allowed in tests...
Expand Down
143 changes: 69 additions & 74 deletions src/autotarcompress/backup_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
from typing import TYPE_CHECKING

from autotarcompress.metadata import update_backup_metadata
from autotarcompress.utils.hash_utils import calculate_sha256
from autotarcompress.utils.progress_bar import SimpleProgressBar
from autotarcompress.utils.size_calculator import SizeCalculator
from autotarcompress.utils.utils import (
from autotarcompress.progress_bar import SimpleProgressBar
from autotarcompress.size_calculator import SizeCalculator
from autotarcompress.utils import (
calculate_sha256,
ensure_backup_folder,
validate_and_expand_paths,
)
Expand Down Expand Up @@ -60,67 +60,65 @@ def calculate_total_size(self) -> int:
def run_backup_process(self, total_size: int) -> bool:
"""Run the backup process using tarfile library.

Uses a temporary file + atomic rename to avoid TOCTOU issues
and prevent partial archive corruption.

Args:
total_size: Total size of files to back up, in bytes.

Returns:
True if backup succeeded, False otherwise.
"""
backup_path = Path(self.config.backup_path)
if backup_path.exists():
final_path = Path(self.config.backup_path)
temp_path = final_path.with_suffix(final_path.suffix + ".tmp")
Comment thread
Cyber-Syntax marked this conversation as resolved.

if final_path.exists():
self.logger.warning(
"File already exists: %s",
self.config.backup_path,
"Backup file already exists: %s",
final_path,
)
return False # Let command handle prompting

total_size_gb = total_size / 1024**3

self.logger.info(
"Starting backup to %s",
self.config.backup_path,
)
self.logger.info(
"Total size: %.2f GB",
total_size_gb,
)
self.logger.info("Starting backup to %s", final_path)
self.logger.info("Total size: %.2f GB", total_size_gb)

# Use tarfile library for backup with progress bar
return self._run_backup_with_tarfile(total_size)

def _run_backup_with_tarfile(self, total_size: int) -> bool:
"""Create backup using tarfile library with progress tracking.

Args:
total_size: Total size in bytes for progress calculation

Returns:
True if backup succeeded, False otherwise
"""
progress = SimpleProgressBar(total_size)
initial_dev: int | None = None

try:
# Open tar file with zstd compression
# zstd compression level can be customized via preset
# parameter (1-22). Using default (preset=3) for balanced
# speed/ratio
with tarfile.open(str(self.config.backup_path), "w:zst") as tar:
# Write to temporary file first
with tarfile.open(str(temp_path), "w:zst") as tar:
initial_dev: int | None = None

for directory in self.config.dirs_to_backup:
dir_path = Path(directory)

# Get device ID for --one-file-system behavior
if initial_dev is None:
initial_dev = dir_path.stat().st_dev

self._add_directory_to_tar(
tar, dir_path, progress, initial_dev
)

progress.finish()

# Atomic replace (this eliminates race conditions)
Path.replace(temp_path, final_path)
Comment thread
Cyber-Syntax marked this conversation as resolved.

except OSError, tarfile.TarError:
self.logger.exception("Backup failed")

# Cleanup partial archive
try:
if temp_path.exists():
temp_path.unlink()
except OSError:
self.logger.exception("Failed to clean up temp backup file")

return False

else:
progress.finish()
self.logger.info("Backup completed successfully")
return True

Expand Down Expand Up @@ -268,7 +266,7 @@ def save_backup_metadata_with_hash(self, backup_path: Path) -> None:
backup_path,
backup_hash,
)
except FileNotFoundError, OSError:
except OSError:
self.logger.exception(
"Failed to calculate backup hash or save metadata"
)
Expand All @@ -288,85 +286,82 @@ def execute_backup(self) -> bool:
Returns:
True if backup succeeded, False otherwise
"""
# Validate and ensure backup directories
existing_dirs, missing_dirs = validate_and_expand_paths(
self.config.dirs_to_backup
)

if missing_dirs:
# Log missing directories; no need to print to stdout here.
self.logger.warning(
"Some configured backup directories do not exist: %s",
missing_dirs,
)

# Use equality comparison instead of identity; lists with the same
# contents should be compared by value.
if existing_dirs != self.config.dirs_to_backup:
self.logger.info(
"Proceeding with existing directories only: %s",
existing_dirs,
)
self.config.dirs_to_backup = existing_dirs

# Ensure backup folder exists
try:
backup_folder_path = ensure_backup_folder(
self.config.backup_folder
)
self.config.backup_folder = str(backup_folder_path)
self.logger.info(
"Backup folder ensured at: %s",
self.config.backup_folder,
)
except OSError:
self.logger.exception("Failed to ensure backup folder")
return False

if not self.config.dirs_to_backup:
self.logger.error(
"No directories configured for backup. Skipping backup."
)
self.logger.error("No directories configured for backup.")
return False

# Calculate total size
total_size: int = self.calculate_total_size()
if total_size == 0:
self.logger.warning(
"Total backup size is 0 bytes. Nothing to back up."
)
self.logger.warning("Nothing to back up.")
return False

# Check if backup file exists and prompt for overwrite
if self._backup_file_exists():
if not self._prompt_overwrite():
msg = "Backup aborted by user due to existing file."
self.logger.info("%s", msg)
backup_path = Path(self.config.backup_path)

# if the user confirms, we delete the file BEFORE proceeding.
if backup_path.exists():
if not self._prompt_overwrite(backup_path):
self.logger.info("Backup aborted by user.")
return False

# User said yes — delete the existing file now so run_backup_process
# can write a fresh archive in its place.
try:
self._remove_existing_backup()
backup_path.unlink()
Comment thread
Cyber-Syntax marked this conversation as resolved.
self.logger.info(
"Removed existing backup: %s", backup_path.name
)
except OSError:
self.logger.exception("Failed to remove existing backup")
self.logger.exception(
"Failed to remove existing backup file: %s", backup_path
)
return False

# Run backup process
success = self.run_backup_process(total_size)

if success:
self.save_backup_metadata_with_hash(Path(self.config.backup_path))

return success

def _backup_file_exists(self) -> bool:
"""Check if the backup file already exists."""
return Path(self.config.backup_path).exists()
def _prompt_overwrite(self, backup_path: Path) -> bool:
"""Ask the user whether to overwrite an existing backup file.

def _remove_existing_backup(self) -> None:
"""Remove the existing backup file."""
Path(self.config.backup_path).unlink()
self.logger.info(
"Removed existing backup file: %s",
self.config.backup_path,
)
Args:
backup_path: Path to the backup file that already exists.
Shown to the user so they know exactly what
will be deleted.

def _prompt_overwrite(self) -> bool:
"""Prompt user to overwrite existing backup file."""
response = input("Do you want to remove it? (y/n): ").strip().lower()
Returns:
True if the user wants to overwrite, False otherwise.
"""
print(f"Backup already exists: {backup_path}")
response = (
input("Do you want to overwrite it? (y/n): ").strip().lower()
)
return response == "y"
Loading