From 0c15c37ad9160923028d51b4e19f130bef73a2c1 Mon Sep 17 00:00:00 2001 From: Joshua Nwachinemere <217677783+dk3yyyy@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:00:35 +0000 Subject: [PATCH] [verified] Fix interactive CLI history --- README.md | 6 +- main.py | 94 ++++++++++++----- tests/test_main.py | 253 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 326 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index e321a28..77699ab 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Tested with Python 3.14](https://img.shields.io/badge/Tested%20with-Python%203.14-3776AB?logo=python&logoColor=white)](https://www.python.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-2E7D32.svg)](LICENSE) -PassGen uses Python's `secrets` module for cryptographically secure randomness. The web interface does not persist generated values, while CLI history is disabled unless you explicitly enable it for a generation command. +PassGen uses Python's `secrets` module for cryptographically secure randomness. The web interface does not persist generated values, while CLI history is disabled unless you explicitly enable it with `--save-history` or confirm the interactive prompt. ## 🖥️ Web interface @@ -42,7 +42,7 @@ For random passwords, displayed entropy is `log₂` of the exact number of valid - Random choices use Python's `secrets` module. - Passphrases default to six independently selected EFF words (about 77.5 bits). - The web interface returns generated values only to the current page and does not write them to history. -- CLI history is written only when `--save-history` is supplied. +- CLI history is written only when `--save-history` is supplied or the interactive save prompt is confirmed. - History and exports receive owner-only permissions on POSIX systems. - Custom wordlists are constrained to PassGen's local wordlist directory. @@ -91,6 +91,8 @@ uv run python main.py --quick uv run python main.py --length 20 --upper --lower --digits --symbols --save-history ``` +Interactive mode returns to the main menu after each action. Enter `q` from the menu to quit. + ## 🧰 CLI reference | Flag | Description | Default | diff --git a/main.py b/main.py index 2968910..f895ca3 100644 --- a/main.py +++ b/main.py @@ -338,7 +338,11 @@ def get_interactive_options(): console.print("[info]3. View History[/info]") console.print("[info]4. Manage Wordlists[/info]") console.print("[info]5. Export Passwords[/info]") - choice = console.input("\n[info]Choose option (1/2/3/4/5): [/info]").strip() + console.print("[info]q. Quit[/info]") + choice = console.input("\n[info]Choose option (1/2/3/4/5/q): [/info]").strip().lower() + + if choice == "q": + return "quit", None, None, None, None, None, None, None, None, False, None if choice == "3": return "history", None, None, None, None, None, None, None, None, False, None @@ -427,8 +431,11 @@ def manage_wordlists_interactive(): words_input = console.input("[info]Enter words (comma-separated): [/info]").strip() words = [w.strip() for w in words_input.split(",") if w.strip()] if words: - save_wordlist(name, words) - console.print(f"[success]Wordlist '{name}' created with {len(words)} words![/success]") + try: + save_wordlist(name, words) + console.print(f"[success]Wordlist '{name}' created with {len(words)} words![/success]") + except (ValueError, OSError) as error: + console.print(f"[error]{error}[/error]") else: console.print("[warning]No words provided.[/warning]") elif choice == "3": @@ -438,10 +445,15 @@ def manage_wordlists_interactive(): return "list", None, None, None, None, None, None, None, None, False, None name = console.input("[info]Enter wordlist name to delete: [/info]").strip() if name in wordlists: - (WORDLIST_DIR / f"{name}.txt").unlink() - console.print(f"[success]Wordlist '{name}' deleted![/success]") + try: + wordlist_path(name).unlink() + console.print(f"[success]Wordlist '{name}' deleted![/success]") + except (ValueError, OSError) as error: + console.print(f"[error]{error}[/error]") else: console.print("[error]Wordlist not found.[/error]") + else: + console.print("[warning]Invalid option.[/warning]") return "list", None, None, None, None, None, None, None, None, False, None @@ -463,10 +475,14 @@ def export_interactive(): choice = console.input("\n[info]Choose option (1/2/3): [/info]").strip() export_history = history - category = None - - if choice == "3": - categories = set(h.get("category", "") for h in history if h.get("category")) + if choice == "1": + format_choice = "json" + elif choice == "2": + format_choice = "csv" + elif choice == "3": + categories = sorted( + {h.get("category", "") for h in history if h.get("category")} + ) if not categories: console.print("[warning]No categorized passwords to export.[/warning]") return "list", None, None, None, None, None, None, None, None, False, None @@ -475,10 +491,16 @@ def export_interactive(): console.print(f" - {c}") category = console.input("[info]Enter category: [/info]").strip() export_history = [h for h in history if h.get("category") == category] - - format_choice = console.input("[info]Export format (json/csv): [/info]").strip().lower() - if format_choice not in ("json", "csv"): - format_choice = "json" + if not export_history: + console.print(f"[warning]No passwords found in category '{category}'.[/warning]") + return "list", None, None, None, None, None, None, None, None, False, None + format_choice = console.input("[info]Export format (json/csv): [/info]").strip().lower() + if format_choice not in ("json", "csv"): + console.print("[warning]Invalid format. Using JSON.[/warning]") + format_choice = "json" + else: + console.print("[warning]Invalid option.[/warning]") + return "list", None, None, None, None, None, None, None, None, False, None filename = f"passwords.{format_choice}" export_path = CONFIG_DIR / filename @@ -505,7 +527,7 @@ def copy_to_clipboard(text: str): return False -def main(): +def _run_once() -> bool: ensure_config_dir() parser = argparse.ArgumentParser(description="Secure Password Generator") @@ -583,29 +605,29 @@ def main(): } save_config(config) console.print("[success]Config saved successfully![/success]") - return + return False if args.history: password_history.show(console, args.category if args.category else None) - return + return False if args.clear_history: password_history.history.clear() password_history.save() console.print("[success]History cleared![/success]") - return + return False if args.export: if not password_history.history: console.print("[warning]No passwords to export.[/warning]") - return + return False export_history = password_history.history if args.category: export_history = [h for h in export_history if h.get("category") == args.category] export_path = CONFIG_DIR / f"exported_passwords.{args.export}" export_passwords(export_history, args.export, str(export_path)) console.print(f"[success]Exported {len(export_history)} passwords to {export_path}![/success]") - return + return False if args.wordlist: global PASSPHRASE_WORDLIST @@ -613,17 +635,24 @@ def main(): PASSPHRASE_WORDLIST = load_custom_wordlist(args.wordlist) except ValueError as e: console.print(f"[error]{e}[/error]") - return + return False is_interactive = args.interactive or len(sys.argv) == 1 result = None word_count = 0 include_number = False + save_history = args.save_history if is_interactive: result = get_interactive_options() - if result[0] in ("history", "list", "export"): - return + if result[0] == "quit": + console.print("[success]Goodbye![/success]") + return False + if result[0] == "history": + password_history.show(console) + return True + if result[0] in ("list", "export"): + return True elif result[0] == "passphrase": p_type, word_count, separator, capitalize, _, _, _, count, _, copy_flag, category = result passphrase_mode = True @@ -637,6 +666,13 @@ def main(): if args.copy: copy_flag = True + if not save_history: + save_history = ( + console.input( + "[info]Save to local plaintext history? [y/N]: [/info]" + ).lower() + == "y" + ) else: passphrase_mode = args.passphrase length = args.length @@ -760,7 +796,7 @@ def main(): strength, entropy, category or "", - persist=args.save_history, + persist=save_history, ) table.add_row( str(i), @@ -777,9 +813,17 @@ def main(): else: console.print("\n[error]✗ Failed to copy to clipboard.[/error]") + return is_interactive + except ValueError as e: console.print(f"[error]Error: {e}[/error]") exit(1) - + + +def main(): + while _run_once(): + pass + + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/test_main.py b/tests/test_main.py index d90c6bb..107a628 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -322,6 +322,245 @@ def test_cli_batch_does_not_save_history_without_opt_in( assert not main_module.HISTORY_FILE.exists() + def test_interactive_view_history_displays_saved_entries( + self, monkeypatch, tmp_path, capsys + ): + self.configure_paths(monkeypatch, tmp_path) + history = main_module.PasswordHistory() + history.history = [ + { + "password": "savedpwd", + "type": "Random", + "strength": "Strong", + "entropy": 80.0, + "timestamp": "2026-07-24 12:00:00", + "category": "test", + } + ] + monkeypatch.setattr(main_module, "password_history", history) + monkeypatch.setattr(sys, "argv", ["passgen", "--interactive"]) + responses = iter(["3", "q"]) + monkeypatch.setattr(main_module.console, "input", lambda _: next(responses)) + + main_module.main() + + assert "savedpwd" in capsys.readouterr().out + + def test_interactive_menu_loops_until_q(self, monkeypatch, tmp_path, capsys): + self.configure_paths(monkeypatch, tmp_path) + history = main_module.PasswordHistory() + history.history = [ + { + "password": "savedpwd", + "type": "Random", + "strength": "Strong", + "entropy": 80.0, + "timestamp": "2026-07-24 12:00:00", + "category": "test", + } + ] + monkeypatch.setattr(main_module, "password_history", history) + monkeypatch.setattr(sys, "argv", ["passgen", "--interactive"]) + responses = iter(["3", "q"]) + monkeypatch.setattr(main_module.console, "input", lambda _: next(responses)) + + main_module.main() + + output = capsys.readouterr().out + assert output.count("Interactive Mode") == 2 + assert "savedpwd" in output + assert "Goodbye" in output + + def test_interactive_q_exits_without_generating_or_saving( + self, monkeypatch, tmp_path, capsys + ): + self.configure_paths(monkeypatch, tmp_path) + monkeypatch.setattr(main_module, "password_history", main_module.PasswordHistory()) + monkeypatch.setattr(sys, "argv", ["passgen", "--interactive"]) + monkeypatch.setattr(main_module.console, "input", lambda _: "Q") + + def fail_if_called(_): + raise AssertionError("password generation must not run when quitting") + + monkeypatch.setattr(main_module.secrets, "choice", fail_if_called) + + main_module.main() + + assert "Goodbye" in capsys.readouterr().out + assert not main_module.HISTORY_FILE.exists() + + def test_interactive_wordlist_create_list_delete_and_return_to_menu( + self, monkeypatch, tmp_path, capsys + ): + self.configure_paths(monkeypatch, tmp_path) + monkeypatch.setattr(main_module, "password_history", main_module.PasswordHistory()) + monkeypatch.setattr(sys, "argv", ["passgen", "--interactive"]) + responses = iter( + [ + "4", "2", "custom", "alpha, beta, gamma", + "4", "1", + "4", "3", "custom", + "q", + ] + ) + monkeypatch.setattr(main_module.console, "input", lambda _: next(responses)) + + main_module.main() + + output = capsys.readouterr().out + assert "Wordlist 'custom' created with 3 words" in output + assert "Available wordlists" in output + assert "custom" in output + assert "Wordlist 'custom' deleted" in output + assert output.count("Interactive Mode") == 4 + assert not (main_module.WORDLIST_DIR / "custom.txt").exists() + + def test_interactive_wordlist_invalid_name_returns_to_menu( + self, monkeypatch, tmp_path, capsys + ): + self.configure_paths(monkeypatch, tmp_path) + monkeypatch.setattr(main_module, "password_history", main_module.PasswordHistory()) + monkeypatch.setattr(sys, "argv", ["passgen", "--interactive"]) + responses = iter(["4", "2", "../outside", "alpha, beta", "q"]) + monkeypatch.setattr(main_module.console, "input", lambda _: next(responses)) + + main_module.main() + + output = capsys.readouterr().out + assert "Invalid wordlist name" in output + assert "Goodbye" in output + assert not (tmp_path / "outside.txt").exists() + + @pytest.mark.parametrize( + ("menu_choice", "expected_format"), + [("1", "json"), ("2", "csv")], + ) + def test_interactive_export_choice_selects_format_and_returns_to_menu( + self, monkeypatch, tmp_path, capsys, menu_choice, expected_format + ): + self.configure_paths(monkeypatch, tmp_path) + history = main_module.PasswordHistory() + history.history = [ + { + "password": "savedpwd", + "type": "Random", + "strength": "Strong", + "entropy": 80.0, + "timestamp": "2026-07-24 12:00:00", + "category": "wifi", + } + ] + monkeypatch.setattr(main_module, "password_history", history) + monkeypatch.setattr(sys, "argv", ["passgen", "--interactive"]) + responses = iter(["5", menu_choice, "q"]) + monkeypatch.setattr(main_module.console, "input", lambda _: next(responses)) + + main_module.main() + + export_path = main_module.CONFIG_DIR / f"passwords.{expected_format}" + output = capsys.readouterr().out + assert export_path.exists() + assert stat.S_IMODE(export_path.stat().st_mode) == 0o600 + assert "Exported 1 passwords" in output + assert "Goodbye" in output + + def test_interactive_export_by_category_filters_entries( + self, monkeypatch, tmp_path, capsys + ): + self.configure_paths(monkeypatch, tmp_path) + history = main_module.PasswordHistory() + history.history = [ + {"password": "wifi-password", "category": "wifi"}, + {"password": "email-password", "category": "email"}, + ] + monkeypatch.setattr(main_module, "password_history", history) + monkeypatch.setattr(sys, "argv", ["passgen", "--interactive"]) + responses = iter(["5", "3", "wifi", "json", "q"]) + monkeypatch.setattr(main_module.console, "input", lambda _: next(responses)) + + main_module.main() + + exported = json.loads((main_module.CONFIG_DIR / "passwords.json").read_text()) + output = capsys.readouterr().out + assert exported == [{"password": "wifi-password", "category": "wifi"}] + assert "Exported 1 passwords" in output + assert "Goodbye" in output + + def test_interactive_export_with_no_history_returns_to_menu( + self, monkeypatch, tmp_path, capsys + ): + self.configure_paths(monkeypatch, tmp_path) + monkeypatch.setattr(main_module, "password_history", main_module.PasswordHistory()) + monkeypatch.setattr(sys, "argv", ["passgen", "--interactive"]) + responses = iter(["5", "q"]) + monkeypatch.setattr(main_module.console, "input", lambda _: next(responses)) + + main_module.main() + + output = capsys.readouterr().out + assert "No passwords to export" in output + assert "Goodbye" in output + + def test_interactive_generation_can_opt_in_to_history( + self, monkeypatch, tmp_path + ): + self.configure_paths(monkeypatch, tmp_path) + monkeypatch.setattr(main_module, "password_history", main_module.PasswordHistory()) + monkeypatch.setattr(sys, "argv", ["passgen", "--interactive"]) + responses = iter( + [ + "1", + "12", + "y", + "y", + "y", + "n", + "n", + "", + "1", + "wifi", + "n", + "y", + "q", + ] + ) + monkeypatch.setattr(main_module.console, "input", lambda _: next(responses)) + + main_module.main() + + stored = json.loads(main_module.HISTORY_FILE.read_text()) + assert len(stored) == 1 + assert stored[0]["category"] == "wifi" + + def test_interactive_generation_does_not_save_when_prompt_is_declined( + self, monkeypatch, tmp_path + ): + self.configure_paths(monkeypatch, tmp_path) + monkeypatch.setattr(main_module, "password_history", main_module.PasswordHistory()) + monkeypatch.setattr(sys, "argv", ["passgen", "--interactive"]) + responses = iter( + [ + "1", + "12", + "y", + "y", + "y", + "n", + "n", + "", + "1", + "", + "n", + "n", + "q", + ] + ) + monkeypatch.setattr(main_module.console, "input", lambda _: next(responses)) + + main_module.main() + + assert not main_module.HISTORY_FILE.exists() + def test_existing_history_permissions_are_tightened(self, monkeypatch, tmp_path): config_dir = self.configure_paths(monkeypatch, tmp_path) config_dir.mkdir(mode=0o755) @@ -416,6 +655,20 @@ def test_load_wordlist_rejects_symlink_escape(self, monkeypatch, tmp_path): with pytest.raises(ValueError, match="outside the wordlist directory"): main_module.load_custom_wordlist("safe") + def test_save_wordlist_rejects_symlink_escape_without_overwriting_target( + self, monkeypatch, tmp_path + ): + self.configure_paths(monkeypatch, tmp_path) + main_module.WORDLIST_DIR.mkdir(parents=True) + outside_path = tmp_path / "outside.txt" + outside_path.write_text("do not overwrite") + (main_module.WORDLIST_DIR / "safe.txt").symlink_to(outside_path) + + with pytest.raises(ValueError, match="outside the wordlist directory"): + main_module.save_wordlist("safe", ["replacement"]) + + assert outside_path.read_text() == "do not overwrite" + def test_valid_wordlist_round_trip(self, monkeypatch, tmp_path): self.configure_paths(monkeypatch, tmp_path)