From b6f35a6a35705173a0c862cca74effe16ce235f4 Mon Sep 17 00:00:00 2001 From: riteshr19 Date: Fri, 27 Mar 2026 12:40:22 -0500 Subject: [PATCH] feat: add decrypt mode via --decrypt flag (closes #28) --- README.md | 13 +- docs/CLI_OPTIONS.md | 10 +- passifypdf/cli.py | 45 ++++-- passifypdf/encryptpdf.py | 91 ++++++++--- .../test_encryptpdf_integration.py | 142 +++++++++++++++- tests/unittests/test_encryptpdf.py | 153 ++++++++++++++---- 6 files changed, 377 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index ea88e13..3a7105e 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ passifypdf --help ``` -Sample Run: +Sample Run (Encrypt): ```bash passifypdf -i input.pdf -o protected.pdf -p mySecretPassword @@ -44,6 +44,15 @@ passifypdf -i input.pdf -o protected.pdf -p mySecretPassword # PDF file encrypted successfully and saved as 'protected.pdf' ``` +Sample Run (Decrypt): +```bash +passifypdf -i protected.pdf -o unlocked.pdf -p mySecretPassword --decrypt + +# -------------------------Sample output---------------------- +# Congratulations! +# PDF file decrypted successfully and saved as 'unlocked.pdf' +``` + ## Known Issues If you have nay special chars(example: an emoji like Star 🌟) in the PDF file, it gives a minor complain during execution. But it still does the job, so you can ingore that "char or object error" which you see in the output. @@ -53,4 +62,4 @@ In general you can use passifypdf to protect your PDF files against chance attac But you should not rely on this for mission-critical data or situation. ## Build & Run Locally -Visit [BUILD.md](BUILD.md) \ No newline at end of file +Visit [BUILD.md](BUILD.md) diff --git a/docs/CLI_OPTIONS.md b/docs/CLI_OPTIONS.md index 6442abc..806b777 100644 --- a/docs/CLI_OPTIONS.md +++ b/docs/CLI_OPTIONS.md @@ -5,14 +5,18 @@ The `passifypdf` tool accepts the following command-line arguments: | Option | Long Option | Required | Description | | :--- | :--- | :--- | :--- | | `-i` | `--input` | Yes | Path to the input PDF file. | -| `-o` | `--output` | Yes | Path to the output (encrypted) PDF file. | -| `-p` | `--passwd` | Yes | Password to use for encryption. **Avoid passing sensitive passwords directly on the command line, as they may be exposed via process listings and shell history.** | +| `-o` | `--output` | Yes | Path to the output (encrypted/decrypted) PDF file. | +| `-p` | `--passwd` | Yes | Password to use for encryption/decryption. **Avoid passing sensitive passwords directly on the command line, as they may be exposed via process listings and shell history.** | +| | `--decrypt` | No | Decrypt an encrypted PDF instead of encrypting a plain PDF. | | `-v` | `--version` | No | Show the program's version number and exit. | | `-h` | `--help` | No | Show the help message and exit. | ## Example Usage ```bash -# Using the installed CLI command (after `poetry install`) +# Encrypt a PDF passifypdf -i input.pdf -o protected.pdf -p mySecretPassword + +# Decrypt a password-protected PDF +passifypdf -i protected.pdf -o unlocked.pdf -p mySecretPassword --decrypt ``` diff --git a/passifypdf/cli.py b/passifypdf/cli.py index 7364188..daf6bc4 100644 --- a/passifypdf/cli.py +++ b/passifypdf/cli.py @@ -1,13 +1,32 @@ -import argparse - - -def get_arg_parser() -> argparse.ArgumentParser: - arg_parser = argparse.ArgumentParser( - description="Encrypt a PDF file with a password of your choice.", - epilog="For more information, visit: https://github.com/SUPAIDEAS/passifypdf" - ) - arg_parser.add_argument("-v", "--version", action="version", version="%(prog)s 1.0") - arg_parser.add_argument("-i", "--input", required=True, help="Path to the input PDF file to be encrypted") - arg_parser.add_argument("-o", "--output", required=True, help="Path where the encrypted PDF file will be saved") - arg_parser.add_argument("-p", "--passwd", required=True, type=str, help="Password to encrypt the PDF file with") - return arg_parser \ No newline at end of file +import argparse + + +def get_arg_parser() -> argparse.ArgumentParser: + arg_parser = argparse.ArgumentParser( + description=( + "Encrypt a PDF file with a password, or decrypt an encrypted PDF " + "using --decrypt." + ), + epilog="For more information, visit: https://github.com/SUPAIDEAS/passifypdf", + ) + arg_parser.add_argument("-v", "--version", action="version", version="%(prog)s 1.0") + arg_parser.add_argument("-i", "--input", required=True, help="Path to the input PDF file") + arg_parser.add_argument( + "-o", + "--output", + required=True, + help="Path where the processed PDF file (encrypted/decrypted) will be saved", + ) + arg_parser.add_argument( + "-p", + "--passwd", + required=True, + type=str, + help="Password to encrypt/decrypt the PDF file", + ) + arg_parser.add_argument( + "--decrypt", + action="store_true", + help="Decrypt an encrypted PDF instead of encrypting a plain PDF", + ) + return arg_parser diff --git a/passifypdf/encryptpdf.py b/passifypdf/encryptpdf.py index e326cab..9d1cace 100644 --- a/passifypdf/encryptpdf.py +++ b/passifypdf/encryptpdf.py @@ -1,14 +1,37 @@ -"""Core PDF encryption module.""" +"""Core PDF encryption and decryption module.""" import sys from pathlib import Path -from typing import Union +from typing import Tuple, Union from pypdf import PdfReader, PdfWriter +from pypdf.errors import PdfReadError from .cli import get_arg_parser +def _load_pdf_reader(input_pdf: Union[str, Path]) -> Tuple[Path, PdfReader]: + """Validate that input is an existing PDF file and return its reader.""" + input_path = Path(input_pdf) + if not input_path.exists(): + raise FileNotFoundError(f"Input file '{input_pdf}' not found.") + + if not input_path.is_file(): + raise IsADirectoryError(f"Input path '{input_pdf}' is not a file.") + + invalid_pdf_error = ValueError(f"Input file '{input_pdf}' is not a valid PDF file.") + with input_path.open("rb") as source_file: + if source_file.read(5) != b"%PDF-": + raise invalid_pdf_error + + try: + reader = PdfReader(input_path) + except PdfReadError as exc: + raise invalid_pdf_error from exc + + return input_path, reader + + def encrypt_pdf(input_pdf: Union[str, Path], output_pdf: Union[str, Path], password: str) -> None: """ Encrypts a PDF file with a password. @@ -21,32 +44,48 @@ def encrypt_pdf(input_pdf: Union[str, Path], output_pdf: Union[str, Path], passw Raises: FileNotFoundError: If the input PDF file does not exist. IsADirectoryError: If the input path is a directory. - Exception: For other errors during processing. + ValueError: If the input file is not a valid PDF. """ - input_path = Path(input_pdf) - if not input_path.exists(): - raise FileNotFoundError(f"Input file '{input_pdf}' not found.") + _, reader = _load_pdf_reader(input_pdf) + writer = PdfWriter() - if not input_path.is_file(): - raise IsADirectoryError(f"Input path '{input_pdf}' is not a file.") + for page in reader.pages: + writer.add_page(page) - try: - reader = PdfReader(input_path) - writer = PdfWriter() + writer.encrypt(password) - # Add all pages from the reader to the writer - for page in reader.pages: - writer.add_page(page) + with open(output_pdf, "wb") as f: + writer.write(f) - # Encrypt with a password - writer.encrypt(password) - # Write the encrypted PDF to a new PDF file passed as param - with open(output_pdf, "wb") as f: - writer.write(f) +def decrypt_pdf(input_pdf: Union[str, Path], output_pdf: Union[str, Path], password: str) -> None: + """ + Decrypts a password-protected PDF file. - except Exception as e: - raise Exception(f"Failed to encrypt PDF: {e}") + Args: + input_pdf (Union[str, Path]): Path to the encrypted input PDF file. + output_pdf (Union[str, Path]): Path to the decrypted output PDF file. + password (str): Password to decrypt the PDF with. + + Raises: + FileNotFoundError: If the input PDF file does not exist. + IsADirectoryError: If the input path is a directory. + ValueError: If input is not a valid PDF, is not encrypted, or password is incorrect. + """ + _, reader = _load_pdf_reader(input_pdf) + + if not reader.is_encrypted: + raise ValueError(f"Input file '{input_pdf}' is not encrypted.") + + if reader.decrypt(password) == 0: + raise ValueError(f"Failed to decrypt '{input_pdf}'. Please provide the correct password.") + + writer = PdfWriter() + for page in reader.pages: + writer.add_page(page) + + with open(output_pdf, "wb") as f: + writer.write(f) def main() -> int: @@ -60,8 +99,12 @@ def main() -> int: args = arg_parser.parse_args() try: - encrypt_pdf(args.input, args.output, args.passwd) - print(f"Congratulations!\nPDF file encrypted successfully and saved as '{args.output}'") + if args.decrypt: + decrypt_pdf(args.input, args.output, args.passwd) + print(f"Congratulations!\nPDF file decrypted successfully and saved as '{args.output}'") + else: + encrypt_pdf(args.input, args.output, args.passwd) + print(f"Congratulations!\nPDF file encrypted successfully and saved as '{args.output}'") return 0 except Exception as e: print(f"Error: {e}", file=sys.stderr) @@ -69,4 +112,4 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/tests/integrationtests/test_encryptpdf_integration.py b/tests/integrationtests/test_encryptpdf_integration.py index 7cebc00..c3961c5 100644 --- a/tests/integrationtests/test_encryptpdf_integration.py +++ b/tests/integrationtests/test_encryptpdf_integration.py @@ -1,9 +1,13 @@ import os +import subprocess +import sys +import tempfile +from pathlib import Path from unittest import TestCase from pypdf import PdfReader, PdfWriter -from passifypdf.encryptpdf import encrypt_pdf +from passifypdf.encryptpdf import decrypt_pdf, encrypt_pdf class TestPdfIntegrationTests(TestCase): @@ -12,30 +16,154 @@ def setUp(self): self.output_pdf = "test_output.pdf" self.password = "strongpassword" - # Create a dummy PDF writer = PdfWriter() writer.add_blank_page(width=100, height=100) with open(self.input_pdf, "wb") as f: writer.write(f) def tearDown(self): - # Cleanup files if os.path.exists(self.input_pdf): os.remove(self.input_pdf) if os.path.exists(self.output_pdf): os.remove(self.output_pdf) def test_encrypt_pdf_integration(self): - # Encrypt the PDF encrypt_pdf(self.input_pdf, self.output_pdf, self.password) - # Verify output exists self.assertTrue(os.path.exists(self.output_pdf)) - # Verify it is encrypted reader = PdfReader(self.output_pdf) self.assertTrue(reader.is_encrypted) - # Verify we can decrypt it self.assertTrue(reader.decrypt(self.password)) self.assertEqual(len(reader.pages), 1) + + def test_decrypt_pdf_integration(self): + encrypted_path = "test_encrypted.pdf" + decrypted_path = "test_decrypted.pdf" + + try: + encrypt_pdf(self.input_pdf, encrypted_path, self.password) + decrypt_pdf(encrypted_path, decrypted_path, self.password) + + self.assertTrue(os.path.exists(decrypted_path)) + + reader = PdfReader(decrypted_path) + self.assertFalse(reader.is_encrypted) + self.assertEqual(len(reader.pages), 1) + finally: + if os.path.exists(encrypted_path): + os.remove(encrypted_path) + if os.path.exists(decrypted_path): + os.remove(decrypted_path) + + def test_cli_decrypt_pdf_integration(self): + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + input_path = temp_path / "input.pdf" + encrypted_path = temp_path / "encrypted.pdf" + decrypted_path = temp_path / "decrypted.pdf" + + writer = PdfWriter() + writer.add_blank_page(width=100, height=100) + with input_path.open("wb") as f: + writer.write(f) + + encrypt_pdf(input_path, encrypted_path, "secret123") + + result = subprocess.run( + [ + sys.executable, + "-m", + "passifypdf.encryptpdf", + "-i", + str(encrypted_path), + "-o", + str(decrypted_path), + "-p", + "secret123", + "--decrypt", + ], + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(result.returncode, 0) + self.assertIn("PDF file decrypted successfully", result.stdout) + + reader = PdfReader(decrypted_path) + self.assertFalse(reader.is_encrypted) + self.assertEqual(len(reader.pages), 1) + + def test_cli_decrypt_rejects_wrong_password(self): + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + input_path = temp_path / "input.pdf" + encrypted_path = temp_path / "encrypted.pdf" + output_path = temp_path / "decrypted.pdf" + + writer = PdfWriter() + writer.add_blank_page(width=100, height=100) + with input_path.open("wb") as f: + writer.write(f) + + encrypt_pdf(input_path, encrypted_path, "secret123") + + result = subprocess.run( + [ + sys.executable, + "-m", + "passifypdf.encryptpdf", + "-i", + str(encrypted_path), + "-o", + str(output_path), + "-p", + "wrong-password", + "--decrypt", + ], + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(result.returncode, 1) + self.assertFalse(output_path.exists()) + self.assertIn( + f"Error: Failed to decrypt '{encrypted_path}'. Please provide the correct password.", + result.stderr, + ) + + def test_cli_decrypt_rejects_unencrypted_pdf(self): + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + input_path = temp_path / "plain.pdf" + output_path = temp_path / "out.pdf" + + writer = PdfWriter() + writer.add_blank_page(width=100, height=100) + with input_path.open("wb") as f: + writer.write(f) + + result = subprocess.run( + [ + sys.executable, + "-m", + "passifypdf.encryptpdf", + "-i", + str(input_path), + "-o", + str(output_path), + "-p", + "secret123", + "--decrypt", + ], + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(result.returncode, 1) + self.assertFalse(output_path.exists()) + self.assertIn(f"Error: Input file '{input_path}' is not encrypted.", result.stderr) diff --git a/tests/unittests/test_encryptpdf.py b/tests/unittests/test_encryptpdf.py index d8d2aa9..e776954 100644 --- a/tests/unittests/test_encryptpdf.py +++ b/tests/unittests/test_encryptpdf.py @@ -1,61 +1,158 @@ from unittest import TestCase -from unittest.mock import patch, mock_open -from passifypdf.encryptpdf import encrypt_pdf +from unittest.mock import MagicMock, mock_open, patch + +from pypdf.errors import PdfReadError + +from passifypdf.encryptpdf import decrypt_pdf, encrypt_pdf class TestPdfUnitTests(TestCase): - @patch('passifypdf.encryptpdf.PdfReader') - @patch('passifypdf.encryptpdf.PdfWriter') - @patch('passifypdf.encryptpdf.Path') # Mock Path - @patch('builtins.open', new_callable=mock_open) + @patch("passifypdf.encryptpdf.PdfReader") + @patch("passifypdf.encryptpdf.PdfWriter") + @patch("passifypdf.encryptpdf.Path") + @patch("builtins.open", new_callable=mock_open) def test_encrypt_pdf(self, mock_file, mock_path_cls, mock_writer_cls, mock_reader_cls): - # Setup mocks mock_path_instance = mock_path_cls.return_value mock_path_instance.exists.return_value = True mock_path_instance.is_file.return_value = True + mock_source_file = MagicMock() + mock_source_file.read.return_value = b"%PDF-" + mock_path_instance.open.return_value.__enter__.return_value = mock_source_file + mock_reader_instance = mock_reader_cls.return_value - mock_reader_instance.pages = ['page1', 'page2'] - + mock_reader_instance.pages = ["page1", "page2"] + mock_writer_instance = mock_writer_cls.return_value - - # Call the function + encrypt_pdf("input.pdf", "output.pdf", "secret") - - # Verify Path existence check + mock_path_cls.assert_called_with("input.pdf") - mock_path_instance.exists.assert_called() - mock_path_instance.is_file.assert_called() + mock_path_instance.exists.assert_called_once() + mock_path_instance.is_file.assert_called_once() + mock_path_instance.open.assert_called_once_with("rb") - # Verify PdfReader was called with the path object mock_reader_cls.assert_called_with(mock_path_instance) - - # Verify pages were added + self.assertEqual(mock_writer_instance.add_page.call_count, 2) - mock_writer_instance.add_page.assert_any_call('page1') - mock_writer_instance.add_page.assert_any_call('page2') - - # Verify encryption + mock_writer_instance.add_page.assert_any_call("page1") + mock_writer_instance.add_page.assert_any_call("page2") + mock_writer_instance.encrypt.assert_called_with("secret") - - # Verify file write + + mock_file.assert_called_with("output.pdf", "wb") + mock_writer_instance.write.assert_called_with(mock_file()) + + @patch("passifypdf.encryptpdf.PdfReader") + @patch("passifypdf.encryptpdf.PdfWriter") + @patch("passifypdf.encryptpdf.Path") + @patch("builtins.open", new_callable=mock_open) + def test_decrypt_pdf(self, mock_file, mock_path_cls, mock_writer_cls, mock_reader_cls): + mock_path_instance = mock_path_cls.return_value + mock_path_instance.exists.return_value = True + mock_path_instance.is_file.return_value = True + + mock_source_file = MagicMock() + mock_source_file.read.return_value = b"%PDF-" + mock_path_instance.open.return_value.__enter__.return_value = mock_source_file + + mock_reader_instance = mock_reader_cls.return_value + mock_reader_instance.is_encrypted = True + mock_reader_instance.decrypt.return_value = 2 + mock_reader_instance.pages = ["page1", "page2"] + + mock_writer_instance = mock_writer_cls.return_value + + decrypt_pdf("input.pdf", "output.pdf", "secret") + + mock_reader_cls.assert_called_with(mock_path_instance) + mock_reader_instance.decrypt.assert_called_once_with("secret") + + self.assertEqual(mock_writer_instance.add_page.call_count, 2) + mock_writer_instance.add_page.assert_any_call("page1") + mock_writer_instance.add_page.assert_any_call("page2") + mock_file.assert_called_with("output.pdf", "wb") mock_writer_instance.write.assert_called_with(mock_file()) - @patch('passifypdf.encryptpdf.Path') + @patch("passifypdf.encryptpdf.Path") def test_encrypt_pdf_file_not_found(self, mock_path_cls): mock_path_instance = mock_path_cls.return_value mock_path_instance.exists.return_value = False - + with self.assertRaises(FileNotFoundError): encrypt_pdf("nonexistent.pdf", "output.pdf", "secret") - @patch('passifypdf.encryptpdf.Path') + @patch("passifypdf.encryptpdf.Path") def test_encrypt_pdf_is_directory(self, mock_path_cls): mock_path_instance = mock_path_cls.return_value mock_path_instance.exists.return_value = True mock_path_instance.is_file.return_value = False - + with self.assertRaises(IsADirectoryError): encrypt_pdf("directory", "output.pdf", "secret") + + @patch("passifypdf.encryptpdf.Path") + def test_encrypt_pdf_rejects_non_pdf_magic_bytes(self, mock_path_cls): + mock_path_instance = mock_path_cls.return_value + mock_path_instance.exists.return_value = True + mock_path_instance.is_file.return_value = True + + mock_source_file = MagicMock() + mock_source_file.read.return_value = b"NOTPD" + mock_path_instance.open.return_value.__enter__.return_value = mock_source_file + + with self.assertRaisesRegex(ValueError, "Input file 'input.pdf' is not a valid PDF file\\."): + encrypt_pdf("input.pdf", "output.pdf", "secret") + + @patch("passifypdf.encryptpdf.PdfReader", side_effect=PdfReadError("broken pdf")) + @patch("passifypdf.encryptpdf.Path") + def test_encrypt_pdf_rejects_pdfreader_parse_failure(self, mock_path_cls, _mock_reader): + mock_path_instance = mock_path_cls.return_value + mock_path_instance.exists.return_value = True + mock_path_instance.is_file.return_value = True + + mock_source_file = MagicMock() + mock_source_file.read.return_value = b"%PDF-" + mock_path_instance.open.return_value.__enter__.return_value = mock_source_file + + with self.assertRaisesRegex(ValueError, "Input file 'input.pdf' is not a valid PDF file\\."): + encrypt_pdf("input.pdf", "output.pdf", "secret") + + @patch("passifypdf.encryptpdf.PdfReader") + @patch("passifypdf.encryptpdf.Path") + def test_decrypt_pdf_rejects_non_encrypted_file(self, mock_path_cls, mock_reader_cls): + mock_path_instance = mock_path_cls.return_value + mock_path_instance.exists.return_value = True + mock_path_instance.is_file.return_value = True + + mock_source_file = MagicMock() + mock_source_file.read.return_value = b"%PDF-" + mock_path_instance.open.return_value.__enter__.return_value = mock_source_file + + mock_reader_instance = mock_reader_cls.return_value + mock_reader_instance.is_encrypted = False + + with self.assertRaisesRegex(ValueError, "Input file 'input.pdf' is not encrypted\\."): + decrypt_pdf("input.pdf", "output.pdf", "secret") + + @patch("passifypdf.encryptpdf.PdfReader") + @patch("passifypdf.encryptpdf.Path") + def test_decrypt_pdf_rejects_wrong_password(self, mock_path_cls, mock_reader_cls): + mock_path_instance = mock_path_cls.return_value + mock_path_instance.exists.return_value = True + mock_path_instance.is_file.return_value = True + + mock_source_file = MagicMock() + mock_source_file.read.return_value = b"%PDF-" + mock_path_instance.open.return_value.__enter__.return_value = mock_source_file + + mock_reader_instance = mock_reader_cls.return_value + mock_reader_instance.is_encrypted = True + mock_reader_instance.decrypt.return_value = 0 + + with self.assertRaisesRegex( + ValueError, "Failed to decrypt 'input.pdf'. Please provide the correct password\\." + ): + decrypt_pdf("input.pdf", "output.pdf", "secret")