diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcadb2c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text eol=lf diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 2404994..867fae0 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -8,7 +8,10 @@ on: jobs: build: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 diff --git a/README.md b/README.md index 3846529..64d6dab 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. For instance, the maximum size of the index before the Nostr backup starts to have problems is unknown. Additionally, the security of the program's memory management and logs has not been evaluated and may leak sensitive information. --- +### Supported OS + +✔ Windows 10/11 • macOS 12+ • Any modern Linux +SeedPass now uses the `portalocker` library for cross-platform file locking. No WSL or Cygwin required. + ## Table of Contents @@ -79,7 +84,7 @@ Activate the virtual environment using the appropriate command for your operatin source venv/bin/activate ``` -- **On Windows:** (Note: SeedPass currently does not support Windows) +- **On Windows:** ```bash venv\Scripts\activate diff --git a/src/main.py b/src/main.py index 782fb91..a72488f 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,6 @@ # main.py import os +from pathlib import Path import sys import logging import signal @@ -23,13 +24,13 @@ def configure_logging(): logger.removeHandler(handler) # Ensure the 'logs' directory exists - log_directory = "logs" - if not os.path.exists(log_directory): - os.makedirs(log_directory) + log_directory = Path("logs") + if not log_directory.exists(): + log_directory.mkdir(parents=True, exist_ok=True) # Create handlers c_handler = logging.StreamHandler(sys.stdout) - f_handler = logging.FileHandler(os.path.join(log_directory, "main.log")) + f_handler = logging.FileHandler(log_directory / "main.log") # Set levels: only errors and critical messages will be shown in the console c_handler.setLevel(logging.ERROR) diff --git a/src/nostr/client.py b/src/nostr/client.py index 1912137..68a5059 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -17,13 +17,12 @@ from monstr.event.event import Event import threading import uuid -import fcntl from .key_manager import KeyManager from .encryption_manager import EncryptionManager from .event_handler import EventHandler from constants import APP_DIR -from utils.file_lock import lock_file +from utils.file_lock import exclusive_lock # Get the logger for this module logger = logging.getLogger(__name__) @@ -416,7 +415,7 @@ class NostrClient: json.dumps(data).encode("utf-8") ) index_file_path = self.fingerprint_dir / "seedpass_passwords_db.json.enc" - with lock_file(index_file_path, fcntl.LOCK_EX): + with exclusive_lock(index_file_path): with open(index_file_path, "wb") as f: f.write(encrypted_data) logger.debug(f"Encrypted data saved to {index_file_path}.") @@ -442,7 +441,7 @@ class NostrClient: checksum_file = self.fingerprint_dir / "seedpass_passwords_db_checksum.txt" - with lock_file(checksum_file, fcntl.LOCK_EX): + with exclusive_lock(checksum_file): with open(checksum_file, "w") as f: f.write(checksum) @@ -465,7 +464,7 @@ class NostrClient: :return: Decrypted data as bytes. """ try: - with lock_file(file_path, fcntl.LOCK_SH): + with exclusive_lock(file_path): with open(file_path, "rb") as f: encrypted_data = f.read() decrypted_data = self.encryption_manager.decrypt_data(encrypted_data) diff --git a/src/password_manager/backup.py b/src/password_manager/backup.py index 25ee279..2f35c31 100644 --- a/src/password_manager/backup.py +++ b/src/password_manager/backup.py @@ -16,11 +16,10 @@ import os import shutil import time import traceback -import fcntl from pathlib import Path from termcolor import colored -from utils.file_lock import lock_file +from utils.file_lock import exclusive_lock from constants import APP_DIR # Instantiate the logger @@ -144,7 +143,7 @@ class BackupManager: return try: - with lock_file(backup_file, lock_type=fcntl.LOCK_SH): + with exclusive_lock(backup_file): shutil.copy2(backup_file, self.index_file) logger.info(f"Restored the index file from backup '{backup_file}'.") print( diff --git a/src/password_manager/encryption.py b/src/password_manager/encryption.py index 8d3866e..2875071 100644 --- a/src/password_manager/encryption.py +++ b/src/password_manager/encryption.py @@ -24,8 +24,9 @@ from typing import Optional from cryptography.fernet import Fernet, InvalidToken from termcolor import colored -from utils.file_lock import lock_file # Ensure this utility is correctly implemented -import fcntl # For file locking +from utils.file_lock import ( + exclusive_lock, +) # Ensure this utility is correctly implemented # Instantiate the logger logger = logging.getLogger(__name__) @@ -77,7 +78,7 @@ class EncryptionManager: encrypted_data = self.encrypt_data(data) # Write the encrypted data to the file with locking - with lock_file(self.parent_seed_file, fcntl.LOCK_EX): + with exclusive_lock(self.parent_seed_file): with open(self.parent_seed_file, "wb") as f: f.write(encrypted_data) @@ -107,7 +108,7 @@ class EncryptionManager: """ try: parent_seed_path = self.fingerprint_dir / "parent_seed.enc" - with lock_file(parent_seed_path, fcntl.LOCK_SH): + with exclusive_lock(parent_seed_path): with open(parent_seed_path, "rb") as f: encrypted_data = f.read() @@ -188,7 +189,7 @@ class EncryptionManager: encrypted_data = self.encrypt_data(data) # Write the encrypted data to the file with locking - with lock_file(file_path, fcntl.LOCK_EX): + with exclusive_lock(file_path): with open(file_path, "wb") as f: f.write(encrypted_data) @@ -220,7 +221,7 @@ class EncryptionManager: file_path = self.fingerprint_dir / relative_path # Read the encrypted data with locking - with lock_file(file_path, fcntl.LOCK_SH): + with exclusive_lock(file_path): with open(file_path, "rb") as f: encrypted_data = f.read() @@ -351,7 +352,7 @@ class EncryptionManager: checksum_file = file_path.parent / f"{file_path.stem}_checksum.txt" # Write the checksum to the file with locking - with lock_file(checksum_file, fcntl.LOCK_EX): + with exclusive_lock(checksum_file): with open(checksum_file, "w") as f: f.write(checksum) @@ -392,7 +393,7 @@ class EncryptionManager: ) return None - with lock_file(self.fingerprint_dir / relative_path, fcntl.LOCK_SH): + with exclusive_lock(self.fingerprint_dir / relative_path): with open(self.fingerprint_dir / relative_path, "rb") as file: encrypted_data = file.read() diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 5ccad2b..88fb7be 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -29,9 +29,8 @@ from pathlib import Path from termcolor import colored from password_manager.encryption import EncryptionManager -from utils.file_lock import lock_file +from utils.file_lock import exclusive_lock -import fcntl # Instantiate the logger logger = logging.getLogger(__name__) @@ -407,7 +406,8 @@ class EntryManager: :param backup_path: The file path of the backup to restore from. """ try: - if not os.path.exists(backup_path): + backup_path = Path(backup_path) + if not backup_path.exists(): logger.error(f"Backup file '{backup_path}' does not exist.") print( colored( diff --git a/src/requirements.txt b/src/requirements.txt index b195427..690ee04 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -10,4 +10,5 @@ bcrypt bip85 pytest>=7.0 pytest-cov +portalocker>=2.8 diff --git a/src/tests/test_file_lock.py b/src/tests/test_file_lock.py new file mode 100644 index 0000000..3dd98e1 --- /dev/null +++ b/src/tests/test_file_lock.py @@ -0,0 +1,36 @@ +import multiprocessing as mp +import time +from pathlib import Path + +from utils.file_lock import exclusive_lock + + +def _hold_lock(path: Path, hold_time: float, started: mp.Event): + with exclusive_lock(path): + started.set() + time.sleep(hold_time) + + +def _try_lock(path: Path, wait_time: mp.Value): + t0 = time.perf_counter() + with exclusive_lock(path): + wait_time.value = time.perf_counter() - t0 + + +def test_exclusive_lock_blocks_until_released(tmp_path: Path): + file_path = tmp_path / "locktest.txt" + + started = mp.Event() + wait_time = mp.Value("d", 0.0) + + p1 = mp.Process(target=_hold_lock, args=(file_path, 1.0, started)) + p2 = mp.Process(target=_try_lock, args=(file_path, wait_time)) + + p1.start() + started.wait() + p2.start() + + p1.join() + p2.join() + + assert wait_time.value >= 1.0 diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 1c3e453..3329ebf 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -4,7 +4,7 @@ import logging import traceback try: - from .file_lock import lock_file + from .file_lock import exclusive_lock from .key_derivation import derive_key_from_password, derive_key_from_parent_seed from .checksum import calculate_checksum, verify_checksum from .password_prompt import prompt_for_password @@ -19,6 +19,6 @@ __all__ = [ "derive_key_from_parent_seed", "calculate_checksum", "verify_checksum", - "lock_file", + "exclusive_lock", "prompt_for_password", ] diff --git a/src/utils/file_lock.py b/src/utils/file_lock.py index 431eea3..e838f33 100644 --- a/src/utils/file_lock.py +++ b/src/utils/file_lock.py @@ -1,142 +1,23 @@ -# utils/file_lock.py - -""" -File Lock Module - -This module provides a single context manager, `lock_file`, for acquiring and releasing -locks on files using the `fcntl` library. It ensures that critical files are accessed -safely, preventing race conditions and maintaining data integrity when multiple processes -or threads attempt to read from or write to the same file concurrently. - -I need to change this to something that supports Windows in the future. - -Ensure that all dependencies are installed and properly configured in your environment. -""" - -import os -import fcntl -import logging +"""File-based locking utilities using portalocker for cross-platform support.""" from contextlib import contextmanager -from typing import Generator +from typing import Generator, Optional from pathlib import Path -from termcolor import colored -import sys -import traceback - -# Instantiate the logger -logger = logging.getLogger(__name__) +import portalocker @contextmanager -def lock_file(file_path: Path, lock_type: int) -> Generator[None, None, None]: +def exclusive_lock( + path: Path, timeout: Optional[float] = None +) -> Generator[None, None, None]: + """Context manager that locks *path* exclusively. + + The function opens the file in binary append mode and obtains an + exclusive lock using ``portalocker``. If ``timeout`` is provided, + acquiring the lock will wait for at most that many seconds before + raising ``portalocker.exceptions.LockException``. """ - Context manager to acquire a lock on a file. - - Parameters: - file_path (Path): The path to the file to lock. - lock_type (int): The type of lock to acquire (`fcntl.LOCK_EX` for exclusive, - `fcntl.LOCK_SH` for shared). - - Yields: - None - - Raises: - ValueError: If an invalid lock type is provided. - SystemExit: Exits the program if the lock cannot be acquired. - """ - if lock_type not in (fcntl.LOCK_EX, fcntl.LOCK_SH): - logging.error( - f"Invalid lock type: {lock_type}. Use fcntl.LOCK_EX or fcntl.LOCK_SH." - ) - print(colored("Error: Invalid lock type provided.", "red")) - sys.exit(1) - - file = None - try: - # Determine the mode based on whether the file exists - mode = "rb+" if file_path.exists() else "wb" - - # Open the file - file = open(file_path, mode) - logging.debug(f"Opened file '{file_path}' in mode '{mode}' for locking.") - - # Acquire the lock - fcntl.flock(file, lock_type) - lock_type_str = "Exclusive" if lock_type == fcntl.LOCK_EX else "Shared" - logging.debug(f"{lock_type_str} lock acquired on '{file_path}'.") - yield # Control is transferred to the block inside the `with` statement - - except IOError as e: - lock_type_str = "exclusive" if lock_type == fcntl.LOCK_EX else "shared" - logging.error(f"Failed to acquire {lock_type_str} lock on '{file_path}': {e}") - logging.error(traceback.format_exc()) # Log full traceback - print( - colored( - f"Error: Failed to acquire {lock_type_str} lock on '{file_path}': {e}", - "red", - ) - ) - sys.exit(1) - - finally: - if file: - try: - # Release the lock - fcntl.flock(file, fcntl.LOCK_UN) - logging.debug(f"Lock released on '{file_path}'.") - except Exception as e: - lock_type_str = "exclusive" if lock_type == fcntl.LOCK_EX else "shared" - logging.warning( - f"Failed to release {lock_type_str} lock on '{file_path}': {e}" - ) - logging.error(traceback.format_exc()) # Log full traceback - print( - colored( - f"Warning: Failed to release {lock_type_str} lock on '{file_path}': {e}", - "yellow", - ) - ) - finally: - # Close the file - try: - file.close() - logging.debug(f"File '{file_path}' closed successfully.") - except Exception as e: - logging.warning(f"Failed to close file '{file_path}': {e}") - logging.error(traceback.format_exc()) # Log full traceback - print( - colored( - f"Warning: Failed to close file '{file_path}': {e}", - "yellow", - ) - ) - - -@contextmanager -def exclusive_lock(file_path: Path) -> Generator[None, None, None]: - """ - Convenience context manager to acquire an exclusive lock on a file. - - Parameters: - file_path (Path): The path to the file to lock. - - Yields: - None - """ - with lock_file(file_path, fcntl.LOCK_EX): - yield - - -@contextmanager -def shared_lock(file_path: Path) -> Generator[None, None, None]: - """ - Convenience context manager to acquire a shared lock on a file. - - Parameters: - file_path (Path): The path to the file to lock. - - Yields: - None - """ - with lock_file(file_path, fcntl.LOCK_SH): - yield + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + lock = portalocker.Lock(str(path), mode="a+b", timeout=timeout) + with lock as fh: + yield fh