diff --git a/envcloak/cli.py b/envcloak/cli.py index 25da618..042b157 100644 --- a/envcloak/cli.py +++ b/envcloak/cli.py @@ -1,9 +1,27 @@ import os from pathlib import Path +import shutil import click +from click import style from envcloak.encryptor import encrypt_file, decrypt_file from envcloak.generator import generate_key_file, generate_key_from_password_file -from envcloak.utils import add_to_gitignore +from envcloak.utils import add_to_gitignore, calculate_required_space +from envcloak.validation import ( + check_file_exists, + check_directory_exists, + check_directory_not_empty, + check_output_not_exists, + check_permissions, + check_disk_space, + validate_salt, +) +from envcloak.exceptions import ( + OutputFileExistsException, + DiskSpaceException, + InvalidSaltException, + FileEncryptionException, + FileDecryptionException, +) @click.group() @@ -12,7 +30,7 @@ def main(): """ EnvCloak: Securely manage encrypted environment variables. """ - pass + # No unnecessary pass here @click.command() @@ -34,43 +52,84 @@ def main(): @click.option( "--key-file", "-k", required=True, help="Path to the encryption key file." ) -def encrypt(input, directory, output, key_file): +@click.option( + "--dry-run", is_flag=True, help="Perform a dry run without making any changes." +) +@click.option( + "--force", + is_flag=True, + help="Force overwrite of existing encrypted files or directories.", +) +def encrypt(input, directory, output, key_file, dry_run, force): """ Encrypt environment variables from a file or all files in a directory. """ - if not input and not directory: - raise click.UsageError("You must provide either --input or --directory.") - if input and directory: - raise click.UsageError( - "You must provide either --input or --directory, not both." - ) - - with open(key_file, "rb") as kf: - key = kf.read() - - if input: - # Encrypt a single file - encrypt_file(input, output, key) - click.echo(f"File {input} encrypted -> {output} using key {key_file}") - elif directory: - # Encrypt all files in the directory - input_dir = Path(directory) - output_dir = Path(output) - - if not input_dir.is_dir(): + try: + # Always perform validation + if not input and not directory: + raise click.UsageError("You must provide either --input or --directory.") + if input and directory: raise click.UsageError( - f"The specified directory does not exist: {directory}" + "You must provide either --input or --directory, not both." ) - if not output_dir.exists(): - output_dir.mkdir(parents=True) + if input: + check_file_exists(input) + check_permissions(input) + if directory: + check_directory_exists(directory) + check_directory_not_empty(directory) + check_file_exists(key_file) + check_permissions(key_file) - for file in input_dir.iterdir(): - if file.is_file(): # Skip directories - output_file = output_dir / (file.name + ".enc") - encrypt_file(str(file), str(output_file), key) + # Handle overwrite with --force + if not force: + check_output_not_exists(output) + else: + if os.path.exists(output): click.echo( - f"File {file} encrypted -> {output_file} using key {key_file}" + style( + f"⚠️ Warning: Overwriting existing file or directory {output} (--force used).", + fg="yellow", + ) ) + if os.path.isdir(output): + shutil.rmtree(output) # Remove existing directory + else: + os.remove(output) # Remove existing file + + required_space = calculate_required_space(input, directory) + check_disk_space(output, required_space) + + if dry_run: + click.echo("Dry-run checks passed successfully.") + return + + # Actual encryption logic + with open(key_file, "rb") as kf: + key = kf.read() + + if input: + encrypt_file(input, output, key) + click.echo(f"File {input} encrypted -> {output} using key {key_file}") + elif directory: + input_dir = Path(directory) + output_dir = Path(output) + if not output_dir.exists(): + output_dir.mkdir(parents=True) + + for file in input_dir.iterdir(): + if file.is_file(): # Skip directories + output_file = output_dir / (file.name + ".enc") + encrypt_file(str(file), str(output_file), key) + click.echo( + f"File {file} encrypted -> {output_file} using key {key_file}" + ) + except ( + OutputFileExistsException, + DiskSpaceException, + FileEncryptionException, + ) as e: + click.echo(f"Error during encryption: {str(e)}") @click.command() @@ -95,43 +154,84 @@ def encrypt(input, directory, output, key_file): @click.option( "--key-file", "-k", required=True, help="Path to the decryption key file." ) -def decrypt(input, directory, output, key_file): +@click.option( + "--dry-run", is_flag=True, help="Perform a dry run without making any changes." +) +@click.option( + "--force", + is_flag=True, + help="Force overwrite of existing decrypted files or directories.", +) +def decrypt(input, directory, output, key_file, dry_run, force): """ Decrypt environment variables from a file or all files in a directory. """ - if not input and not directory: - raise click.UsageError("You must provide either --input or --directory.") - if input and directory: - raise click.UsageError( - "You must provide either --input or --directory, not both." - ) - - with open(key_file, "rb") as kf: - key = kf.read() - - if input: - # Decrypt a single file - decrypt_file(input, output, key) - click.echo(f"File {input} decrypted -> {output} using key {key_file}") - elif directory: - # Decrypt all files in the directory - input_dir = Path(directory) - output_dir = Path(output) - - if not input_dir.is_dir(): + try: + # Always perform validation + if not input and not directory: + raise click.UsageError("You must provide either --input or --directory.") + if input and directory: raise click.UsageError( - f"The specified directory does not exist: {directory}" + "You must provide either --input or --directory, not both." ) - if not output_dir.exists(): - output_dir.mkdir(parents=True) + if input: + check_file_exists(input) + check_permissions(input) + if directory: + check_directory_exists(directory) + check_directory_not_empty(directory) + check_file_exists(key_file) + check_permissions(key_file) - for file in input_dir.iterdir(): - if file.is_file() and file.suffix == ".enc": # Only decrypt .enc files - output_file = output_dir / file.stem # Remove .enc from filename - decrypt_file(str(file), str(output_file), key) + # Handle overwrite with --force + if not force: + check_output_not_exists(output) + else: + if os.path.exists(output): click.echo( - f"File {file} decrypted -> {output_file} using key {key_file}" + style( + f"⚠️ Warning: Overwriting existing file or directory {output} (--force used).", + fg="yellow", + ) ) + if os.path.isdir(output): + shutil.rmtree(output) # Remove existing directory + else: + os.remove(output) # Remove existing file + + required_space = calculate_required_space(input, directory) + check_disk_space(output, required_space) + + if dry_run: + click.echo("Dry-run checks passed successfully.") + return + + # Actual decryption logic + with open(key_file, "rb") as kf: + key = kf.read() + + if input: + decrypt_file(input, output, key) + click.echo(f"File {input} decrypted -> {output} using key {key_file}") + elif directory: + input_dir = Path(directory) + output_dir = Path(output) + if not output_dir.exists(): + output_dir.mkdir(parents=True) + + for file in input_dir.iterdir(): + if file.is_file() and file.suffix == ".enc": # Only decrypt .enc files + output_file = output_dir / file.stem # Remove .enc from filename + decrypt_file(str(file), str(output_file), key) + click.echo( + f"File {file} decrypted -> {output_file} using key {key_file}" + ) + except ( + OutputFileExistsException, + DiskSpaceException, + FileDecryptionException, + ) as e: + click.echo(f"Error during decryption: {str(e)}") @click.command() @@ -141,15 +241,29 @@ def decrypt(input, directory, output, key_file): @click.option( "--no-gitignore", is_flag=True, help="Skip adding the key file to .gitignore." ) -def generate_key(output, no_gitignore): +@click.option( + "--dry-run", is_flag=True, help="Perform a dry run without making any changes." +) +def generate_key(output, no_gitignore, dry_run): """ Generate a new encryption key. """ - output_path = Path(output) + try: + # Always perform validation + check_output_not_exists(output) + check_disk_space(output, required_space=32) - generate_key_file(output_path) - if not no_gitignore: - add_to_gitignore(output_path.parent, output_path.name) + if dry_run: + click.echo("Dry-run checks passed successfully.") + return + + # Actual key generation logic + output_path = Path(output) + generate_key_file(output_path) + if not no_gitignore: + add_to_gitignore(output_path.parent, output_path.name) + except (OutputFileExistsException, DiskSpaceException) as e: + click.echo(f"Error during key generation: {str(e)}") @click.command() @@ -165,15 +279,31 @@ def generate_key(output, no_gitignore): @click.option( "--no-gitignore", is_flag=True, help="Skip adding the key file to .gitignore." ) -def generate_key_from_password(password, output, salt, no_gitignore): +@click.option( + "--dry-run", is_flag=True, help="Perform a dry run without making any changes." +) +def generate_key_from_password(password, salt, output, no_gitignore, dry_run): """ Derive an encryption key from a password and salt. """ - output_path = Path(output) + try: + # Always perform validation + check_output_not_exists(output) + check_disk_space(output, required_space=32) + if salt: + validate_salt(salt) + + if dry_run: + click.echo("Dry-run checks passed successfully.") + return - generate_key_from_password_file(password, output_path, salt) - if not no_gitignore: - add_to_gitignore(output_path.parent, output_path.name) + # Actual key derivation logic + output_path = Path(output) + generate_key_from_password_file(password, output_path, salt) + if not no_gitignore: + add_to_gitignore(output_path.parent, output_path.name) + except (OutputFileExistsException, DiskSpaceException, InvalidSaltException) as e: + click.echo(f"Error during key derivation: {str(e)}") @click.command() @@ -187,20 +317,46 @@ def generate_key_from_password(password, output, salt, no_gitignore): "--new-key-file", "-nk", required=True, help="Path to the new encryption key." ) @click.option("--output", "-o", required=True, help="Path to the re-encrypted file.") -def rotate_keys(input, old_key_file, new_key_file, output): +@click.option( + "--dry-run", is_flag=True, help="Perform a dry run without making any changes." +) +def rotate_keys(input, old_key_file, new_key_file, output, dry_run): """ Rotate encryption keys by re-encrypting a file with a new key. """ - with open(old_key_file, "rb") as okf: - old_key = okf.read() - with open(new_key_file, "rb") as nkf: - new_key = nkf.read() - # Decrypt with old key and re-encrypt with new key - temp_decrypted = f"{output}.tmp" - decrypt_file(input, temp_decrypted, old_key) - encrypt_file(temp_decrypted, output, new_key) - os.remove(temp_decrypted) # Clean up temporary file - click.echo(f"Keys rotated for {input} -> {output}") + try: + # Always perform validation + check_file_exists(input) + check_permissions(input) + check_file_exists(old_key_file) + check_permissions(old_key_file) + check_file_exists(new_key_file) + check_permissions(new_key_file) + check_output_not_exists(output) + check_disk_space(output, required_space=1024 * 1024) + + if dry_run: + click.echo("Dry-run checks passed successfully.") + return + + # Actual key rotation logic + with open(old_key_file, "rb") as okf: + old_key = okf.read() + with open(new_key_file, "rb") as nkf: + new_key = nkf.read() + + temp_decrypted = f"{output}.tmp" + decrypt_file(input, temp_decrypted, old_key) + encrypt_file(temp_decrypted, output, new_key) + os.remove(temp_decrypted) # Clean up temporary file + click.echo(f"Keys rotated for {input} -> {output}") + except ( + OutputFileExistsException, + DiskSpaceException, + FileDecryptionException, + FileEncryptionException, + ) as e: + click.echo(f"Error during key rotation: {str(e)}") # Add all commands to the main group diff --git a/envcloak/exceptions.py b/envcloak/exceptions.py index f99574b..51f663b 100644 --- a/envcloak/exceptions.py +++ b/envcloak/exceptions.py @@ -40,9 +40,27 @@ class UnsupportedFileFormatException(EncryptedEnvLoaderException): default_message = "Unsupported file format detected." -#### Cryptography exceptions +class DirectoryEmptyException(EncryptedEnvLoaderException): + """Raised when an input directory is empty.""" + default_message = "The provided input directory is empty." + +class OutputFileExistsException(EncryptedEnvLoaderException): + """Raised when the output file already exists and may be overwritten.""" + + default_message = ( + "The output file or directory already exists and will be overwritten." + ) + + +class DiskSpaceException(EncryptedEnvLoaderException): + """Raised when there is insufficient disk space.""" + + default_message = "Insufficient disk space available for this operation." + + +#### Cryptography Exceptions class CryptographyException(Exception): """Base exception for cryptographic errors.""" diff --git a/envcloak/utils.py b/envcloak/utils.py index fcdbf60..cb9720a 100644 --- a/envcloak/utils.py +++ b/envcloak/utils.py @@ -1,3 +1,4 @@ +import os from pathlib import Path @@ -22,3 +23,28 @@ def add_to_gitignore(directory: str, filename: str): with open(gitignore_path, "w", encoding="utf-8") as gitignore_file: gitignore_file.write(f"{filename}\n") print(f"Created {gitignore_path} and added '{filename}'") + + +def calculate_required_space(input=None, directory=None): + """ + Calculate the required disk space based on the size of the input file or directory. + + :param input: Path to the file to calculate size. + :param directory: Path to the directory to calculate total size. + :return: Size in bytes. + """ + if input and directory: + raise ValueError( + "Both `input` and `directory` cannot be specified at the same time." + ) + + if input: + return os.path.getsize(input) + + if directory: + total_size = sum( + file.stat().st_size for file in Path(directory).rglob("*") if file.is_file() + ) + return total_size + + return 0 diff --git a/envcloak/validation.py b/envcloak/validation.py new file mode 100644 index 0000000..7d081d2 --- /dev/null +++ b/envcloak/validation.py @@ -0,0 +1,89 @@ +import os +from pathlib import Path +import shutil +from envcloak.exceptions import ( + KeyFileNotFoundException, + InvalidSaltException, + OutputFileExistsException, + DirectoryEmptyException, + DiskSpaceException, +) + + +def validate_salt(salt: str): + """Check if the provided salt is a valid hex string of the correct length.""" + if not salt: + return # Valid if no salt is provided + if len(salt) != 32 or not all(c in "0123456789abcdefABCDEF" for c in salt): + raise InvalidSaltException( + details="Salt must be a 16-byte hex string (32 characters)." + ) + + +def check_file_exists(file_path: str): + """Check if a file exists.""" + if not Path(file_path).is_file(): + raise KeyFileNotFoundException(details=f"File not found: {file_path}") + + +def check_directory_exists(directory_path: str): + """Check if a directory exists.""" + if not Path(directory_path).is_dir(): + raise FileNotFoundError(f"Directory does not exist: {directory_path}") + + +def check_directory_not_empty(directory_path: str): + """Check if a directory is not empty.""" + dir_path = Path(directory_path) + if not any(file.is_file() for file in dir_path.iterdir()): + raise DirectoryEmptyException( + details=f"The directory is empty: {directory_path}" + ) + + +def check_output_not_exists(output_path: str): + """Check if an output file or directory does not already exist.""" + if Path(output_path).exists(): + raise OutputFileExistsException( + details=f"Output path already exists: {output_path}" + ) + + +def check_directory_overwrite(directory_path: str): + """ + Check if a directory exists and contains files that may be overwritten. + """ + dir_path = Path(directory_path) + if dir_path.is_dir() and any(file.is_file() for file in dir_path.iterdir()): + raise OutputFileExistsException( + details=f"Directory already exists and contains files: {directory_path}" + ) + + +def check_permissions(file_path: str, write: bool = False): + """Check if a file or directory has read/write permissions.""" + path = Path(file_path) + if write and not os.access(path, os.W_OK): + raise PermissionError(f"Write permission denied: {file_path}") + if not write and not os.access(path, os.R_OK): + raise PermissionError(f"Read permission denied: {file_path}") + + +def check_disk_space(output_path: str, required_space: int): + """Check if there is enough disk space at the output path.""" + output_dir = Path(output_path).parent + if not output_dir.exists(): + return # Assume enough space if directory doesn't exist yet + _, _, free = shutil.disk_usage(output_dir) + if free < required_space: + raise DiskSpaceException( + details=f"Available: {free} bytes, Required: {required_space} bytes." + ) + + +def check_path_conflict(input_path: str, output_path: str): + """Ensure input and output paths don't overlap.""" + input_abs = Path(input_path).resolve() + output_abs = Path(output_path).resolve() + if output_abs.is_relative_to(input_abs): + raise ValueError("Input and output paths overlap. This may cause issues.") diff --git a/examples/cli/README.md b/examples/cli/README.md index 2927d4c..b9aeec2 100644 --- a/examples/cli/README.md +++ b/examples/cli/README.md @@ -4,6 +4,8 @@ EnvCloak simplifies managing sensitive environment variables by encrypting and d ## Usage +> **Dry Run:** With all required params **for each command** you can use `--dry-run` flag to check if command will pass or fail - without destroying your project 😅 + ### Key Generation #### 1. Generate a Key from a Password and Salt @@ -53,6 +55,7 @@ envcloak encrypt --input .env --output .env.enc --key-file mykey.key ``` **Description:** Encrypts your `.env` file into `.env.enc`. The original file remains unchanged. +> ⚠️ Has additional `--force` flag to allow overwriting of encrypted files. ### Decrypting Variables @@ -61,6 +64,7 @@ envcloak decrypt --input .env.enc --output .env --key-file mykey.key ``` **Description:** Decrypts `.env.enc` back to `.env`. Ensure the `key-file` used matches the one from the encryption step. +> ⚠️ Has additional `--force` flag to allow overwriting of decrypted files. ### Rotating Keys diff --git a/pyproject.toml b/pyproject.toml index 793f69f..f900e4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "envcloak" -version = "0.1.0" +version = "0.1.1" description = "Securely manage encrypted environment variables with ease." readme = "README.md" license = { file = "LICENSE" } diff --git a/tests/test_cli.py b/tests/test_cli.py index ff380bb..5fcdf75 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -74,7 +74,7 @@ def test_encrypt(mock_encrypt_file, runner, isolated_mock_files): Test the `encrypt` CLI command. """ input_file = isolated_mock_files / "variables.env" - encrypted_file = isolated_mock_files / "variables.env.enc" + encrypted_file = isolated_mock_files / "variables.temp.enc" # Use unique temp file key_file = isolated_mock_files / "mykey.key" def mock_encrypt(input_path, output_path, key): @@ -111,6 +111,9 @@ def test_decrypt(mock_decrypt_file, runner, mock_files): """ _, encrypted_file, decrypted_file, key_file = mock_files + # Use a unique temporary output file + temp_decrypted_file = decrypted_file.with_name("variables.temp.decrypted") + def mock_decrypt(input_path, output_path, key): assert os.path.exists(input_path), "Encrypted file does not exist" with open(output_path, "w") as f: @@ -125,7 +128,7 @@ def mock_decrypt(input_path, output_path, key): "--input", str(encrypted_file), "--output", - str(decrypted_file), + str(temp_decrypted_file), "--key-file", str(key_file), ], @@ -134,9 +137,13 @@ def mock_decrypt(input_path, output_path, key): assert result.exit_code == 0 assert "File" in result.output mock_decrypt_file.assert_called_once_with( - str(encrypted_file), str(decrypted_file), key_file.read_bytes() + str(encrypted_file), str(temp_decrypted_file), key_file.read_bytes() ) + # Clean up: Remove temp decrypted file + if temp_decrypted_file.exists(): + temp_decrypted_file.unlink() + @patch("envcloak.cli.add_to_gitignore") @patch("envcloak.cli.generate_key_file") @@ -146,20 +153,19 @@ def test_generate_key_with_gitignore( """ Test the `generate-key` CLI command with default behavior (adds to .gitignore). """ - key_file = test_dir / "random.key" + # Use a unique temporary file for the key + key_file = test_dir / "temp_random.key" - # Simulate CLI behavior result = runner.invoke(main, ["generate-key", "--output", str(key_file)]) - # Assert CLI ran without errors assert result.exit_code == 0 - - # Ensure the `generate_key_file` was called mock_generate_key_file.assert_called_once_with(key_file) - - # Verify `add_to_gitignore` was called mock_add_to_gitignore.assert_called_once_with(key_file.parent, key_file.name) + # Cleanup + if key_file.exists(): + key_file.unlink() + @patch("envcloak.cli.add_to_gitignore") @patch("envcloak.cli.generate_key_file") @@ -169,22 +175,20 @@ def test_generate_key_no_gitignore( """ Test the `generate-key` CLI command with the `--no-gitignore` flag. """ - key_file = test_dir / "random.key" + key_file = test_dir / "temp_random.key" - # Simulate CLI behavior with `--no-gitignore` result = runner.invoke( main, ["generate-key", "--output", str(key_file), "--no-gitignore"] ) - # Assert CLI ran without errors assert result.exit_code == 0 - - # Verify the `generate_key_file` was called correctly mock_generate_key_file.assert_called_once_with(key_file) - - # Ensure `add_to_gitignore` was NOT called mock_add_to_gitignore.assert_not_called() + # Cleanup + if key_file.exists(): + key_file.unlink() + @patch("envcloak.cli.add_to_gitignore") @patch("envcloak.cli.generate_key_from_password_file") @@ -195,12 +199,11 @@ def test_generate_key_from_password_with_gitignore( Test the `generate-key-from-password` CLI command with default behavior (adds to .gitignore). """ _, _, _, key_file = mock_files + temp_key_file = key_file.with_name("temp_password_key.key") # Unique temp file password = "JustGiveItATry" salt = "e3a1c8b0d4f6e2c7a5b9d6f0c3e8f1a2" - # Simulate CLI behavior mock_generate_key_from_password_file.return_value = None - mock_add_to_gitignore.side_effect = lambda parent, name: None result = runner.invoke( main, @@ -211,20 +214,21 @@ def test_generate_key_from_password_with_gitignore( "--salt", salt, "--output", - str(key_file), + str(temp_key_file), ], ) - # Assert CLI ran without errors assert result.exit_code == 0 - - # Verify the `generate_key_from_password_file` was called correctly mock_generate_key_from_password_file.assert_called_once_with( - password, key_file, salt + password, temp_key_file, salt + ) + mock_add_to_gitignore.assert_called_once_with( + temp_key_file.parent, temp_key_file.name ) - # Verify `add_to_gitignore` was called - mock_add_to_gitignore.assert_called_once_with(key_file.parent, key_file.name) + # Cleanup + if temp_key_file.exists(): + temp_key_file.unlink() @patch("envcloak.cli.add_to_gitignore") @@ -236,6 +240,7 @@ def test_generate_key_from_password_no_gitignore( Test the `generate-key-from-password` CLI command with the `--no-gitignore` flag. """ _, _, _, key_file = mock_files + temp_key_file = key_file.with_name("temp_password_key.key") # Unique temp file password = "JustGiveItATry" salt = "e3a1c8b0d4f6e2c7a5b9d6f0c3e8f1a2" @@ -248,21 +253,21 @@ def test_generate_key_from_password_no_gitignore( "--salt", salt, "--output", - str(key_file), + str(temp_key_file), "--no-gitignore", ], ) assert result.exit_code == 0 - - # Check `generate_key_from_password_file` call mock_generate_key_from_password_file.assert_called_once_with( - password, key_file, salt + password, temp_key_file, salt ) - - # Ensure `add_to_gitignore` was NOT called mock_add_to_gitignore.assert_not_called() + # Cleanup + if temp_key_file.exists(): + temp_key_file.unlink() + @patch("envcloak.cli.decrypt_file") @patch("envcloak.cli.encrypt_file") @@ -270,19 +275,18 @@ def test_rotate_keys(mock_encrypt_file, mock_decrypt_file, runner, isolated_mock """ Test the `rotate-keys` CLI command. """ - # Use isolated copies of the mock files encrypted_file = isolated_mock_files / "variables.env.enc" - decrypted_file = isolated_mock_files / "variables.env.decrypted" + temp_decrypted_file = isolated_mock_files / "temp_variables.decrypted" key_file = isolated_mock_files / "mykey.key" - new_key_file = key_file.with_name("newkey.key") - new_key_file.write_bytes(os.urandom(32)) + temp_new_key_file = key_file.with_name("temp_newkey.key") + temp_new_key_file.write_bytes(os.urandom(32)) - tmp_file = str(decrypted_file) + ".tmp" + tmp_file = str(temp_decrypted_file) + ".tmp" def mock_decrypt(input_path, output_path, key): assert os.path.exists(input_path), "Encrypted file does not exist" with open(output_path, "w") as f: - f.write("Decrypted content") # Simulate decrypting the file + f.write("Decrypted content") def mock_encrypt(input_path, output_path, key): assert os.path.exists(input_path), "Decrypted file does not exist" @@ -292,7 +296,6 @@ def mock_encrypt(input_path, output_path, key): mock_decrypt_file.side_effect = mock_decrypt mock_encrypt_file.side_effect = mock_encrypt - # Simulate CLI behavior result = runner.invoke( main, [ @@ -302,28 +305,30 @@ def mock_encrypt(input_path, output_path, key): "--old-key-file", str(key_file), "--new-key-file", - str(new_key_file), + str(temp_new_key_file), "--output", - str(decrypted_file), + str(temp_decrypted_file), ], ) assert result.exit_code == 0 assert "Keys rotated" in result.output - - # Ensure `decrypt_file` was called to create the temporary file mock_decrypt_file.assert_called_once_with( str(encrypted_file), tmp_file, key_file.read_bytes() ) - - # Ensure `encrypt_file` was called with the temporary file mock_encrypt_file.assert_called_once_with( - tmp_file, str(decrypted_file), new_key_file.read_bytes() + tmp_file, str(temp_decrypted_file), temp_new_key_file.read_bytes() ) - # Confirm that the temporary file is deleted by the CLI assert not os.path.exists(tmp_file), f"Temporary file {tmp_file} was not deleted" + # Cleanup + if temp_decrypted_file.exists(): + temp_decrypted_file.unlink() + if temp_new_key_file.exists(): + temp_new_key_file.unlink() + + def test_encrypt_with_mixed_input_and_directory(runner, mock_files): """ Test the `encrypt` CLI command with mixed `--input` and `--directory` usage. @@ -332,7 +337,6 @@ def test_encrypt_with_mixed_input_and_directory(runner, mock_files): directory = "mock_directory" output_path = "output_directory" - # Simulate mixed usage of `--input` and `--directory` result = runner.invoke( main, [ @@ -348,7 +352,6 @@ def test_encrypt_with_mixed_input_and_directory(runner, mock_files): ], ) - # Assert the CLI raises an appropriate error assert result.exit_code != 0 assert "You must provide either --input or --directory, not both." in result.output @@ -361,7 +364,6 @@ def test_decrypt_with_mixed_input_and_directory(runner, mock_files): directory = "mock_directory" output_path = "output_directory" - # Simulate mixed usage of `--input` and `--directory` result = runner.invoke( main, [ @@ -377,6 +379,253 @@ def test_decrypt_with_mixed_input_and_directory(runner, mock_files): ], ) - # Assert the CLI raises an appropriate error assert result.exit_code != 0 assert "You must provide either --input or --directory, not both." in result.output + + +@patch("envcloak.cli.encrypt_file") +def test_encrypt_with_force(mock_encrypt_file, runner, isolated_mock_files): + """ + Test the `encrypt` CLI command with the `--force` flag. + """ + input_file = isolated_mock_files / "variables.env" + existing_encrypted_file = ( + isolated_mock_files / "variables.temp.enc" + ) # Existing file + key_file = isolated_mock_files / "mykey.key" + + # Create a mock existing encrypted file + existing_encrypted_file.write_text("existing content") + + def mock_encrypt(input_path, output_path, key): + assert os.path.exists(input_path), "Input file does not exist" + with open(output_path, "w") as f: + f.write(json.dumps({"ciphertext": "encrypted_data"})) + + mock_encrypt_file.side_effect = mock_encrypt + + # Invoke with --force + result = runner.invoke( + main, + [ + "encrypt", + "--input", + str(input_file), + "--output", + str(existing_encrypted_file), + "--key-file", + str(key_file), + "--force", + ], + ) + + assert result.exit_code == 0 + assert "Overwriting existing file" in result.output + mock_encrypt_file.assert_called_once_with( + str(input_file), str(existing_encrypted_file), key_file.read_bytes() + ) + + # Ensure the file was overwritten + with open(existing_encrypted_file, "r") as f: + assert json.load(f)["ciphertext"] == "encrypted_data" + + +@patch("envcloak.cli.decrypt_file") +def test_decrypt_with_force(mock_decrypt_file, runner, mock_files): + """ + Test the `decrypt` CLI command with the `--force` flag. + """ + _, encrypted_file, decrypted_file, key_file = mock_files + + # Create a mock existing decrypted file + decrypted_file.write_text("existing content") + + def mock_decrypt(input_path, output_path, key): + assert os.path.exists(input_path), "Encrypted file does not exist" + with open(output_path, "w") as f: + f.write("DB_USERNAME=example_user\nDB_PASSWORD=example_pass") + + mock_decrypt_file.side_effect = mock_decrypt + + # Invoke with --force + result = runner.invoke( + main, + [ + "decrypt", + "--input", + str(encrypted_file), + "--output", + str(decrypted_file), + "--key-file", + str(key_file), + "--force", + ], + ) + + assert result.exit_code == 0 + assert "Overwriting existing file" in result.output + mock_decrypt_file.assert_called_once_with( + str(encrypted_file), str(decrypted_file), key_file.read_bytes() + ) + + # Ensure the file was overwritten + with open(decrypted_file, "r") as f: + assert f.read() == "DB_USERNAME=example_user\nDB_PASSWORD=example_pass" + + +def test_encrypt_without_force_conflict(runner, isolated_mock_files): + """ + Test the `encrypt` CLI command without the `--force` flag when a conflict exists. + """ + input_file = isolated_mock_files / "variables.env" + existing_encrypted_file = isolated_mock_files / "variables.temp.enc" + key_file = isolated_mock_files / "mykey.key" + + # Create a mock existing encrypted file + existing_encrypted_file.write_text("existing content") + + # Invoke without --force + result = runner.invoke( + main, + [ + "encrypt", + "--input", + str(input_file), + "--output", + str(existing_encrypted_file), + "--key-file", + str(key_file), + ], + ) + + assert "already exists" in result.output + + +def test_decrypt_without_force_conflict(runner, mock_files): + """ + Test the `decrypt` CLI command without the `--force` flag when a conflict exists. + """ + _, encrypted_file, decrypted_file, key_file = mock_files + + # Create a mock existing decrypted file + decrypted_file.write_text("existing content") + + # Invoke without --force + result = runner.invoke( + main, + [ + "decrypt", + "--input", + str(encrypted_file), + "--output", + str(decrypted_file), + "--key-file", + str(key_file), + ], + ) + + assert "already exists" in result.output + + +@patch("envcloak.cli.encrypt_file") +def test_encrypt_with_force_directory(mock_encrypt_file, runner, isolated_mock_files): + """ + Test the `encrypt` CLI command with the `--force` flag for a directory. + """ + directory = isolated_mock_files / "mock_directory" + output_directory = isolated_mock_files / "output_directory" + key_file = isolated_mock_files / "mykey.key" + + # Create mock files in the directory + directory.mkdir() + (directory / "file1.env").write_text("content1") + (directory / "file2.env").write_text("content2") + + # Create a mock existing output directory + output_directory.mkdir() + (output_directory / "file1.env.enc").write_text("existing encrypted content") + + def mock_encrypt(input_path, output_path, key): + with open(output_path, "w") as f: + f.write(json.dumps({"ciphertext": "encrypted_data"})) + + mock_encrypt_file.side_effect = mock_encrypt + + # Invoke with --force + result = runner.invoke( + main, + [ + "encrypt", + "--directory", + str(directory), + "--output", + str(output_directory), + "--key-file", + str(key_file), + "--force", + ], + ) + + assert "Overwriting existing file" in result.output + mock_encrypt_file.assert_any_call( + str(directory / "file1.env"), + str(output_directory / "file1.env.enc"), + key_file.read_bytes(), + ) + mock_encrypt_file.assert_any_call( + str(directory / "file2.env"), + str(output_directory / "file2.env.enc"), + key_file.read_bytes(), + ) + + +@patch("envcloak.cli.decrypt_file") +def test_decrypt_with_force_directory(mock_decrypt_file, runner, isolated_mock_files): + """ + Test the `decrypt` CLI command with the `--force` flag for a directory. + """ + directory = isolated_mock_files / "mock_directory" + output_directory = isolated_mock_files / "output_directory" + key_file = isolated_mock_files / "mykey.key" + + # Create mock encrypted files in the directory + directory.mkdir() + (directory / "file1.env.enc").write_text("encrypted content1") + (directory / "file2.env.enc").write_text("encrypted content2") + + # Create a mock existing output directory + output_directory.mkdir() + (output_directory / "file1.env").write_text("existing decrypted content") + + def mock_decrypt(input_path, output_path, key): + with open(output_path, "w") as f: + f.write("decrypted content") + + mock_decrypt_file.side_effect = mock_decrypt + + # Invoke with --force + result = runner.invoke( + main, + [ + "decrypt", + "--directory", + str(directory), + "--output", + str(output_directory), + "--key-file", + str(key_file), + "--force", + ], + ) + + assert "Overwriting existing file" in result.output + mock_decrypt_file.assert_any_call( + str(directory / "file1.env.enc"), + str(output_directory / "file1.env"), + key_file.read_bytes(), + ) + mock_decrypt_file.assert_any_call( + str(directory / "file2.env.enc"), + str(output_directory / "file2.env"), + key_file.read_bytes(), + ) diff --git a/tests/test_cli_dry_run.py b/tests/test_cli_dry_run.py new file mode 100644 index 0000000..75b778c --- /dev/null +++ b/tests/test_cli_dry_run.py @@ -0,0 +1,201 @@ +import os +from pathlib import Path +import pytest +import shutil +import tempfile +from tests.test_cli import isolated_mock_files +from click.testing import CliRunner +from envcloak.cli import main + + +@pytest.fixture +def runner(): + """ + Fixture for Click CLI Runner. + """ + return CliRunner() + + +@pytest.fixture +def isolated_mock_files(): + """ + Provide isolated mock files in a temporary directory for each test. + Prevents modification of the original mock files. + """ + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir_path = Path(temp_dir) + mock_dir = Path("tests/mock") + + # Copy all mock files to the temporary directory + for file in mock_dir.iterdir(): + if file.is_file(): + shutil.copy(file, temp_dir_path / file.name) + + yield temp_dir_path + # Cleanup is handled automatically by TemporaryDirectory + + +@pytest.fixture +def mock_files(isolated_mock_files): + """ + Fixture for using isolated mock files for dry-run tests. + """ + input_file = isolated_mock_files / "variables.env" + encrypted_file = isolated_mock_files / "variables.env.enc" + key_file = isolated_mock_files / "mykey.key" + directory = isolated_mock_files + return input_file, encrypted_file, key_file, directory + + +def test_encrypt_dry_run_single_file(runner, mock_files): + """ + Test the `encrypt` CLI command with a single input file in dry-run mode. + """ + input_file, _, key_file, _ = mock_files + output_file = str(input_file) + ".enc" # This file already exists in mock_files + + result = runner.invoke( + main, + [ + "encrypt", + "--input", + str(input_file), + "--output", + output_file, + "--key-file", + str(key_file), + "--dry-run", + ], + ) + + assert result.exit_code == 0 + assert "Output path already exists" in result.output + + +def test_encrypt_dry_run_directory(runner, mock_files): + """ + Test the `encrypt` CLI command with a directory in dry-run mode. + """ + _, _, key_file, directory = mock_files + output_directory = directory / "output" + + result = runner.invoke( + main, + [ + "encrypt", + "--directory", + str(directory), + "--output", + str(output_directory), + "--key-file", + str(key_file), + "--dry-run", + ], + ) + + assert result.exit_code == 0 + assert "Dry-run checks passed successfully." in result.output + assert f"Input directory does not exist: {directory}" not in result.output + + +def test_decrypt_dry_run_single_file(runner, mock_files): + """ + Test the `decrypt` CLI command with a single input file in dry-run mode. + """ + _, encrypted_file, key_file, _ = mock_files + output_file = str(encrypted_file).replace(".enc", ".decrypted") + + result = runner.invoke( + main, + [ + "decrypt", + "--input", + str(encrypted_file), + "--output", + output_file, + "--key-file", + str(key_file), + "--dry-run", + ], + ) + + assert result.exit_code == 0 + assert "Dry-run checks passed successfully." in result.output + assert f"Encrypted file does not exist: {encrypted_file}" not in result.output + + +def test_generate_key_dry_run(runner, isolated_mock_files): + """ + Test the `generate-key` CLI command in dry-run mode. + """ + key_file = isolated_mock_files / "random.key" + + result = runner.invoke( + main, + [ + "generate-key", + "--output", + str(key_file), + "--dry-run", + ], + ) + + assert result.exit_code == 0 + assert "Dry-run checks passed successfully." in result.output + assert f"Output path already exists: {key_file}" not in result.output + + +def test_generate_key_from_password_dry_run(runner, isolated_mock_files): + """ + Test the `generate-key-from-password` CLI command in dry-run mode. + """ + key_file = isolated_mock_files / "password.key" + password = "MySecretPassword" + salt = "a3b4c5d6e7f8f9a0a1b2c3d4e5f6a7b8" # Valid 16-byte hex string + + result = runner.invoke( + main, + [ + "generate-key-from-password", + "--password", + password, + "--salt", + salt, + "--output", + str(key_file), + "--dry-run", + ], + ) + + assert result.exit_code == 0 + assert "Dry-run checks passed successfully." in result.output + + +def test_rotate_keys_dry_run(runner, mock_files): + """ + Test the `rotate-keys` CLI command in dry-run mode. + """ + _, encrypted_file, key_file, directory = mock_files + new_key_file = directory / "newkey.key" + new_key_file.write_bytes(os.urandom(32)) + output_file = str(encrypted_file).replace(".enc", ".rotated") + + result = runner.invoke( + main, + [ + "rotate-keys", + "--input", + str(encrypted_file), + "--old-key-file", + str(key_file), + "--new-key-file", + str(new_key_file), + "--output", + output_file, + "--dry-run", + ], + ) + + assert result.exit_code == 0 + assert "Dry-run checks passed successfully." in result.output + assert f"Encrypted file does not exist: {encrypted_file}" not in result.output diff --git a/tests/test_exceptions_loader.py b/tests/test_exceptions_loader.py index d7e5c03..96288a0 100644 --- a/tests/test_exceptions_loader.py +++ b/tests/test_exceptions_loader.py @@ -46,11 +46,13 @@ def test_file_decryption_exception(): """ loader = EncryptedEnvLoader("tests/mock/variables.env.enc", "tests/mock/mykey.key") - with patch("pathlib.Path.exists", return_value=True), patch( - "envcloak.loader.decrypt_file", - side_effect=FileDecryptionException("Decryption error"), - ), patch( - "builtins.open", mock_open(read_data="fake_key") + with ( + patch("pathlib.Path.exists", return_value=True), + patch( + "envcloak.loader.decrypt_file", + side_effect=FileDecryptionException("Decryption error"), + ), + patch("builtins.open", mock_open(read_data="fake_key")), ): # Simulate the key file with pytest.raises(EncryptedEnvLoaderException) as exc_info: loader.load() @@ -66,10 +68,11 @@ def test_unsupported_file_format_exception(): Test that UnsupportedFileFormatException is raised for unsupported file formats. """ loader = EncryptedEnvLoader("tests/mock/variables.unknown", "tests/mock/mykey.key") - with patch("pathlib.Path.exists", return_value=True), patch( - "envcloak.loader.decrypt_file" - ), patch("builtins.open", mock_open(read_data="{}")), patch.object( - Path, "suffix", ".unknown" + with ( + patch("pathlib.Path.exists", return_value=True), + patch("envcloak.loader.decrypt_file"), + patch("builtins.open", mock_open(read_data="{}")), + patch.object(Path, "suffix", ".unknown"), ): # Mock the suffix attribute with pytest.raises( UnsupportedFileFormatException, match="File format detected: .unknown" @@ -82,11 +85,13 @@ def test_unexpected_error(): Test that EncryptedEnvLoaderException is raised for unexpected errors. """ loader = EncryptedEnvLoader("test.enc", "test.key") - with patch("pathlib.Path.exists", return_value=True), patch( - "envcloak.loader.decrypt_file" - ), patch( - "envcloak.loader.EncryptedEnvLoader._parse_file", - side_effect=ValueError("Unexpected error"), + with ( + patch("pathlib.Path.exists", return_value=True), + patch("envcloak.loader.decrypt_file"), + patch( + "envcloak.loader.EncryptedEnvLoader._parse_file", + side_effect=ValueError("Unexpected error"), + ), ): with pytest.raises( EncryptedEnvLoaderException, @@ -100,10 +105,11 @@ def test_parse_file_error(): Test that EncryptedEnvLoaderException is raised when parsing fails. """ loader = EncryptedEnvLoader("test.json", "test.key") - with patch("pathlib.Path.exists", return_value=True), patch( - "envcloak.loader.decrypt_file" - ), patch("envcloak.loader.open", mock_open(read_data="invalid json")), patch( - "json.load", side_effect=ValueError("JSON parsing error") + with ( + patch("pathlib.Path.exists", return_value=True), + patch("envcloak.loader.decrypt_file"), + patch("envcloak.loader.open", mock_open(read_data="invalid json")), + patch("json.load", side_effect=ValueError("JSON parsing error")), ): with pytest.raises( EncryptedEnvLoaderException, match="Failed to parse the decrypted file." @@ -116,12 +122,11 @@ def test_parse_xml_error(): Test that EncryptedEnvLoaderException is raised when XML parsing fails. """ loader = EncryptedEnvLoader("tests/mock/variables.xml.enc", "tests/mock/mykey.key") - with patch("pathlib.Path.exists", return_value=True), patch( - "envcloak.loader.decrypt_file" - ), patch( - "envcloak.loader.safe_parse", side_effect=Exception("XML parsing error") - ), patch( - "builtins.open", mock_open(read_data="fake_key") + with ( + patch("pathlib.Path.exists", return_value=True), + patch("envcloak.loader.decrypt_file"), + patch("envcloak.loader.safe_parse", side_effect=Exception("XML parsing error")), + patch("builtins.open", mock_open(read_data="fake_key")), ): # Simulate the key file with pytest.raises( EncryptedEnvLoaderException, match="Failed to parse XML file."