From a014af5871831705191c6924fe3c023a03ddcab3 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 19:13:41 -0400 Subject: [PATCH] fix: correct download_left_hashes potfile merge bugs - Delete block that wrongly appended found hashes back into the left (unsolved) file - found hashes belong only in the potfile - Fix get_hcat_potfile_path() to return "" when config key is explicitly set to "", respecting user intent to disable potfile override - Fix get_hcat_potfile_path() to resolve relative paths relative to the config file directory, matching main.py's hate_path resolution - Add potfile_path parameter to download_left_hashes() and download_hashes_from_hashview() so CLI --potfile-path and --no-potfile-path overrides propagate to the API merge step - Update main.py call sites to pass hcatPotfilePath through - Add tests covering all four bug fixes --- hate_crack/api.py | 36 +++++++++++++--------------- hate_crack/main.py | 2 ++ tests/test_api_downloads.py | 16 +++++++++++++ tests/test_cli_flags.py | 2 +- tests/test_hashview.py | 47 +++++++++++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 21 deletions(-) diff --git a/hate_crack/api.py b/hate_crack/api.py index f263e72..db962a3 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -6,7 +6,7 @@ import threading import time from queue import Queue import shutil -from typing import Callable, Tuple +from typing import Callable, Optional, Tuple import requests # type: ignore[import-untyped] from bs4 import BeautifulSoup @@ -135,9 +135,14 @@ def get_hcat_potfile_path(): try: with open(config_path) as f: config = json.load(f) - raw = (config.get("hcatPotfilePath") or "").strip() - if raw: - return os.path.expanduser(raw) + if "hcatPotfilePath" in config: + raw = (config["hcatPotfilePath"] or "").strip() + if raw == "": + return "" + expanded = os.path.expanduser(raw) + if not os.path.isabs(expanded): + expanded = os.path.join(os.path.dirname(config_path), expanded) + return expanded except Exception: pass return os.path.expanduser("~/.hashcat/hashcat.potfile") @@ -827,7 +832,7 @@ class HashviewAPI: return resp.json() def download_left_hashes( - self, customer_id, hashfile_id, output_file=None, hash_type=None + self, customer_id, hashfile_id, output_file=None, hash_type=None, potfile_path=None ): import sys @@ -918,22 +923,11 @@ class HashviewAPI: f"Split found file into {hashes_count} hashes and {clears_count} clears" ) - # Append found hashes to the left file - with open(output_abs, "a", encoding="utf-8") as lf: - with open( - found_hashes_file, "r", encoding="utf-8", errors="ignore" - ) as fhf: - for line in fhf: - line = line.strip() - if line: - lf.write(line + "\n") - print(f"✓ Appended {hashes_count} found hashes to {output_abs}") - # Append found hash:clear pairs to the potfile - potfile_path = get_hcat_potfile_path() - if potfile_path: + resolved_potfile = potfile_path if potfile_path is not None else get_hcat_potfile_path() + if resolved_potfile: appended = 0 - with open(potfile_path, "a", encoding="utf-8") as pf: + with open(resolved_potfile, "a", encoding="utf-8") as pf: with open( found_file, "r", encoding="utf-8", errors="ignore" ) as ff: @@ -944,7 +938,7 @@ class HashviewAPI: appended += 1 combined_count = appended print( - f"✓ Appended {appended} found hashes to potfile: {potfile_path}" + f"✓ Appended {appended} found hashes to potfile: {resolved_potfile}" ) else: print( @@ -1108,6 +1102,7 @@ def download_hashes_from_hashview( debug_mode: bool, input_fn: Callable[[str], str] = input, print_fn: Callable[..., None] = print, + potfile_path: Optional[str] = None, ) -> Tuple[str, str]: """Interactive Hashview download flow used by CLI.""" try: @@ -1242,6 +1237,7 @@ def download_hashes_from_hashview( hashfile_id, output_file, hash_type=selected_hash_type, + potfile_path=potfile_path, ) print_fn(f"\n✓ Success: Downloaded {download_result['size']} bytes") print_fn(f" File: {download_result['output_file']}") diff --git a/hate_crack/main.py b/hate_crack/main.py index 685a5d6..d34c29c 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -3570,6 +3570,7 @@ def hashview_api(): hashfile_id, output_file, hash_type=selected_hash_type, + potfile_path=hcatPotfilePath, ) print(f"\n✓ Success: Downloaded {download_result['size']} bytes") print(f" File: {download_result['output_file']}") @@ -4414,6 +4415,7 @@ def main(): args.customer_id, args.hashfile_id, hash_type=args.hash_type, + potfile_path=hcatPotfilePath, ) print(f"\n✓ Success: Downloaded {download_result['size']} bytes") print(f" File: {download_result['output_file']}") diff --git a/tests/test_api_downloads.py b/tests/test_api_downloads.py index aa358e5..26bca45 100644 --- a/tests/test_api_downloads.py +++ b/tests/test_api_downloads.py @@ -79,6 +79,22 @@ class TestGetHcatPotfilePath: result = get_hcat_potfile_path() assert result == os.path.expanduser("~/.hashcat/hashcat.potfile") + def test_returns_empty_string_when_key_is_empty(self, tmp_path): + config_data = {"hcatPotfilePath": ""} + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(config_data)) + with patch("hate_crack.api._resolve_config_path", return_value=str(config_file)): + result = get_hcat_potfile_path() + assert result == "" + + def test_resolves_relative_path_from_config_dir(self, tmp_path): + config_data = {"hcatPotfilePath": "hashcat.potfile"} + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(config_data)) + with patch("hate_crack.api._resolve_config_path", return_value=str(config_file)): + result = get_hcat_potfile_path() + assert result == str(tmp_path / "hashcat.potfile") + def test_returns_default_when_no_config(self): with patch("hate_crack.api._resolve_config_path", return_value=None): result = get_hcat_potfile_path() diff --git a/tests/test_cli_flags.py b/tests/test_cli_flags.py index 67c9077..db37789 100644 --- a/tests/test_cli_flags.py +++ b/tests/test_cli_flags.py @@ -187,7 +187,7 @@ class DummyHashviewAPI: def __init__(self, base_url, api_key, debug=False): self.calls = [] - def download_left_hashes(self, customer_id, hashfile_id, hash_type=None): + def download_left_hashes(self, customer_id, hashfile_id, hash_type=None, potfile_path=None): self.calls.append( ("download_left_hashes", customer_id, hashfile_id, hash_type) ) diff --git a/tests/test_hashview.py b/tests/test_hashview.py index 973d733..993cc43 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -644,6 +644,18 @@ class TestHashviewAPI: # Verify left file was created assert os.path.exists(result["output_file"]) + # Verify left file contains only the original uncracked hashes + with open(result["output_file"], "r") as f: + left_contents = f.read() + assert "found_hash1" not in left_contents, ( + "Found hashes must NOT be written back into the left file" + ) + assert "found_hash2" not in left_contents, ( + "Found hashes must NOT be written back into the left file" + ) + assert "uncracked_hash1" in left_contents + assert "uncracked_hash2" in left_contents + # Verify found files are cleaned up after merge found_file = tmp_path / "found_1_2.txt" assert not os.path.exists(found_file), ( @@ -659,6 +671,41 @@ class TestHashviewAPI: "Split clears file should be deleted after merge" ) + # Verify potfile received the found hash:plaintext pairs + with open(potfile, "r") as f: + potfile_contents = f.read() + assert "found_hash1:found_password1" in potfile_contents + assert "found_hash2:found_password2" in potfile_contents + + def test_download_left_potfile_path_param_overrides_config(self, api, tmp_path): + """Test that a passed-in potfile_path is used instead of re-reading config.""" + mock_left_response = Mock() + mock_left_response.content = b"hash1\n" + mock_left_response.raise_for_status = Mock() + mock_left_response.headers = {"content-length": "0"} + mock_left_response.iter_content = lambda chunk_size=8192: iter([mock_left_response.content]) + + mock_found_response = Mock() + mock_found_response.content = b"found_hash:plaintext\n" + mock_found_response.raise_for_status = Mock() + mock_found_response.headers = {"content-length": "0"} + mock_found_response.iter_content = lambda chunk_size=8192: iter([mock_found_response.content]) + + api.session.get.side_effect = [mock_left_response, mock_found_response] + + explicit_potfile = str(tmp_path / "explicit.potfile") + other_potfile = str(tmp_path / "other.potfile") + + left_file = tmp_path / "left_1_2.txt" + # Pass potfile_path explicitly - config-derived path should NOT be used + with patch("hate_crack.api.get_hcat_potfile_path", return_value=other_potfile): + api.download_left_hashes(1, 2, output_file=str(left_file), potfile_path=explicit_potfile) + + assert os.path.exists(explicit_potfile), "Explicit potfile should be written" + assert not os.path.exists(other_potfile), "Config-derived potfile should NOT be written" + with open(explicit_potfile, "r") as f: + assert "found_hash:plaintext" in f.read() + def test_download_left_id_matching(self, api, tmp_path): """Test that found hashes only merge when customer_id and hashfile_id match""" # Create .out file with specific IDs