diff --git a/.gitignore b/.gitignore index 894f7bf..4f0fc57 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ env/ desktop.ini/ .ruff_cache/ .mypy_cache/ +write_ups/ diff --git a/pyproject.toml b/pyproject.toml index 4542fbe..3aa5f99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ test = [ ] [project.scripts] -fscrypt = "fscrypt.main:main" +fscrypt = "fscrypt.cli:app" [tool.setuptools.packages.find] where = ["src"] diff --git a/src/fscrypt/cli.py b/src/fscrypt/cli.py new file mode 100644 index 0000000..aea50f5 --- /dev/null +++ b/src/fscrypt/cli.py @@ -0,0 +1,69 @@ +from pathlib import Path +from typing import Annotated, Callable + +import typer + +from . import crypto_utils +from . import file_tools + +app = typer.Typer(help="Encrypt and decrypt text files.") + + +@app.command() +def encrypt_file( + input_file: Annotated[Path, typer.Argument(help="plaintext input file")], + output_file: Annotated[Path, typer.Argument(help="plaintext input file")], + password: Annotated[ + str | None, + typer.Option( + "--password", + "-p", + help="Password to use. If ommited the last line of the file is used", + ), + ] = None, +) -> None: + """ + Encrypt one file with last line. + + If --password is omitted, the final line of the input file is used as the password. + """ + + # Create a encryption function that either uses the last line of text, or password if password is given + encryption_function: Callable[[str], str] + if password is None: + encryption_function = file_tools.encrypt_file_text_from_last_line + else: + given_password: str = password + + def encryption_function(plaintext: str) -> str: + return crypto_utils.encryptStringFromPassword(plaintext, given_password) + + output_file.parent.mkdir(parents=True, exist_ok=True) + file_tools.map_file(input_file, output_file, encryption_function) + + password_explaination: str = "the last line in in the file" + if password is not None: + password_explaination = password + + typer.echo( + f"Encrypted {input_file} -> {output_file} using {password_explaination} as the password" + ) + + +@app.command() +def encrypt_dir( + input_directory: Annotated[ + Path, + typer.Argument( + help="dir full of plaintext input files with the password to each as the last line of text" + ), + ], + output_directory: Annotated[ + Path, + typer.Argument(help="output directory where encrypted files will be placed"), + ], +) -> None: + "Recreate structure of input directory in output director, but every file is encrypted" " with the last line of text used as a password." + file_tools.map_dir( + input_directory, output_directory, file_tools.encrypt_file_text_from_last_line + ) diff --git a/src/fscrypt/file_tools.py b/src/fscrypt/file_tools.py index 8985ed8..504de99 100644 --- a/src/fscrypt/file_tools.py +++ b/src/fscrypt/file_tools.py @@ -1,9 +1,12 @@ from . import crypto_utils from typing import Callable import os +from pathlib import Path -def map_file(oldPath: str, newPath: str, function: Callable[[str], str]) -> None: +def map_file( + oldPath: str | Path, newPath: str | Path, function: Callable[[str], str] +) -> None: # Read the old file with open(oldPath, "r") as oldFile: oldFileText: str = oldFile.read() @@ -16,7 +19,11 @@ def map_file(oldPath: str, newPath: str, function: Callable[[str], str]) -> None newFile.write(encryptedText) -def map_dir(inputDirectory: str, outputDirectory: str, function: Callable[[str], str]): +def map_dir( + inputDirectory: str | Path, + outputDirectory: str | Path, + function: Callable[[str], str], +): # Runs checks sanity checks on inputs if not os.path.exists(inputDirectory): raise RuntimeError( @@ -58,7 +65,7 @@ def strip_commenting(commented_line: str) -> str: return commented_line -def encrypt_file_text_from_answer(input_text: str) -> str: +def encrypt_file_text_from_last_line(input_text: str) -> str: """split text into first and last lines and use last line to encrypt others""" lines: list[str] = input_text.split("\n") body: str = "\n".join(lines[:-1]) @@ -68,4 +75,4 @@ def encrypt_file_text_from_answer(input_text: str) -> str: def encrypt_all_files_in_dir(inputDirectory: str, outputDirectory: str): - map_dir(inputDirectory, outputDirectory, encrypt_file_text_from_answer) + map_dir(inputDirectory, outputDirectory, encrypt_file_text_from_last_line) diff --git a/src/fscrypt/main.py b/src/fscrypt/main.py deleted file mode 100644 index 5cd8bd7..0000000 --- a/src/fscrypt/main.py +++ /dev/null @@ -1,10 +0,0 @@ -unencryptedFolder = "write-ups" -encryptedFolder = "write-ups-encrypted" - - -def main(): - print("App not yet implemented") - - -if __name__ == "__main__": - main() diff --git a/tests/test_crypto_utils.py b/tests/test_crypto_utils.py index 99f7821..d310c47 100644 --- a/tests/test_crypto_utils.py +++ b/tests/test_crypto_utils.py @@ -7,18 +7,12 @@ import unittest import os import random -import string +from testing_utils import create_random_string b64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" -def create_random_string( - length: int, alphabet=string.ascii_letters + string.digits -) -> str: - return "".join([random.choice(alphabet) for _ in range(length)]) - - class TestB64StringUtils(unittest.TestCase): def test_encode_decode(self) -> None: """Checks that you can encode then decode bytes and end up with the same bytes""" diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..3909a2e --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,58 @@ +from pathlib import Path +import subprocess +from testing_utils import PLAINTEXT_TEST_FILES +from fscrypt.crypto_utils import decryptStringFromPassword + + +def run_cli(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["fscrypt", *args], text=True, capture_output=True, check=False + ) + + +def test_encrypt_file_with_pass_e2e(tmp_path: Path): + password: str = "S3cur3P4ss123!" + plaintext: str = "secrete message from alice to bob" + input_file = tmp_path / "plain_text.txt" + input_file.write_text(plaintext) + output_file = tmp_path / "output_file.txt" + + result = run_cli("encrypt-file", str(input_file), str(output_file), "-p", password) + + assert result.returncode == 0 + assert output_file.exists() + assert decryptStringFromPassword(output_file.read_text(), password) == plaintext + assert "Encrypted" in result.stdout + + +def test_encrypt_file_with_no_pass_e2e(tmp_path: Path): + password: str = "S3cur3P4ss123!" + plaintext: str = "secrete message from alice to bob" + body: str = plaintext + "\n" + password + input_file = tmp_path / "plain_text.txt" + input_file.write_text(body) + output_file = tmp_path / "output_file.txt" + + result = run_cli("encrypt-file", str(input_file), str(output_file)) + + assert result.returncode == 0 + assert output_file.exists() + assert decryptStringFromPassword(output_file.read_text(), password) == plaintext + assert "Encrypted" in result.stdout + + +def test_encrypt_dir_recursive_e2e(tmp_path: Path): + for filepath, plaintext, password in PLAINTEXT_TEST_FILES: + filebody: str = plaintext + "\n" + password + full_filepath: Path = tmp_path / "plain_dir" / filepath + full_filepath.parent.mkdir(parents=True, exist_ok=True) + full_filepath.write_text(filebody) + + run_cli("encrypt-dir", str(tmp_path / "plain_dir"), str(tmp_path / "crypt_dir")) + + for path, contents, password in PLAINTEXT_TEST_FILES: + full_filepath = tmp_path / "crypt_dir" / path + assert full_filepath.exists() + encrypted: str = full_filepath.read_text() + decrypted: str = decryptStringFromPassword(encrypted, password) + assert decrypted == contents diff --git a/tests/test_file_tools.py b/tests/test_file_tools.py index 27a666a..5ac7e6b 100644 --- a/tests/test_file_tools.py +++ b/tests/test_file_tools.py @@ -1,32 +1,11 @@ from pathlib import Path from pyfakefs.fake_filesystem import FakeFilesystem +from testing_utils import fake_encryption, PLAINTEXT_TEST_FILES import fscrypt.file_tools as file_tools import fscrypt.crypto_utils as crypto_utils -def fake_encryption(plaintext: str) -> str: - return f"ENCRYPTED({plaintext})" - - -plaintext_test_files: list[tuple[str, str, str]] = [ - ("message1.txt", "Love letter from Bob to Alice, very secure!", "pass1"), - ("message2.txt", "Dear Bob, I am glad to hear you feel the same way", "pass2"), - ("message3.txt", "Let's go watch a movie together on friday?\npass3", "pass3"), - ("diary/entry1.txt", "I have such a crush on bob", "AKfmcIodlp98720"), - ( - "diary/entry2.txt", - "I hope he feels the same way about me\npass3", - "Very Long Password!!!udksPOldSuper3Secure", - ), - ( - "diary/super_secret/self_only.txt", - "my RSA primes are 67 and 722896686382073903939970876463", - "67722896686382073903939970876463", - ), -] - - def test_map_file_transforms_file(fs: FakeFilesystem) -> None: plaintext_test_message: str = ( "Love letter from Bob to Alice, Very important to be kept Secure!" @@ -41,24 +20,24 @@ def test_map_file_transforms_file(fs: FakeFilesystem) -> None: def test_map_dir_transforms_nested_dir(fs: FakeFilesystem) -> None: - for path, contents, _ in plaintext_test_files: + for path, contents, _ in PLAINTEXT_TEST_FILES: fs.create_file(Path("/plaintext_dir") / path, contents=contents) file_tools.map_dir("/plaintext_dir", "/encrypted_dir", fake_encryption) - for path, contents, _ in plaintext_test_files: + for path, contents, _ in PLAINTEXT_TEST_FILES: assert (Path("/encrypted_dir") / path).read_text() == fake_encryption(contents) def test_encrypt_all_files_in_dir_with_fake_dir(fs: FakeFilesystem) -> None: - for path, contents, password in plaintext_test_files: + for path, contents, password in PLAINTEXT_TEST_FILES: fs.create_file( Path("/plaintext_dir") / path, contents=contents + "\n" + password ) file_tools.encrypt_all_files_in_dir("/plaintext_dir", "/encrypted_dir") - for path, contents, password in plaintext_test_files: + for path, contents, password in PLAINTEXT_TEST_FILES: encrypted: str = (Path("/encrypted_dir") / path).read_text() decrypted: str = crypto_utils.decryptStringFromPassword(encrypted, password) assert decrypted == contents diff --git a/tests/testing_utils.py b/tests/testing_utils.py new file mode 100644 index 0000000..2d6847e --- /dev/null +++ b/tests/testing_utils.py @@ -0,0 +1,29 @@ +import random +import string + +PLAINTEXT_TEST_FILES: list[tuple[str, str, str]] = [ + ("message1.txt", "Love letter from Bob to Alice, very secure!", "pass1"), + ("message2.txt", "Dear Bob, I am glad to hear you feel the same way", "pass2"), + ("message3.txt", "Let's go watch a movie together on friday?\npass3", "pass3"), + ("diary/entry1.txt", "I have such a crush on bob", "AKfmcIodlp98720"), + ( + "diary/entry2.txt", + "I hope he feels the same way about me\npass3", + "Very Long Password!!!udksPOldSuper3Secure", + ), + ( + "diary/super_secret/self_only.txt", + "my RSA primes are 67 and 722896686382073903939970876463", + "67722896686382073903939970876463", + ), +] + + +def create_random_string( + length: int, alphabet=string.ascii_letters + string.digits +) -> str: + return "".join([random.choice(alphabet) for _ in range(length)]) + + +def fake_encryption(plaintext: str) -> str: + return f"ENCRYPTED({plaintext})" diff --git a/write_ups/project-euler/1.py b/write_ups/project-euler/1.py deleted file mode 100644 index 9de026a..0000000 --- a/write_ups/project-euler/1.py +++ /dev/null @@ -1 +0,0 @@ -print(sum(filter(lambda n: n % 3 == 0 or n % 5 == 0, range(1000))))