From 70b21d04a80a04553a471280ba202114f8380651 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Tue, 17 Feb 2026 11:05:23 -0500 Subject: [PATCH 1/2] fix: resolve Hashview wordlist downloads to configured directory download_wordlist() now resolves relative filenames against get_hcat_wordlists_dir() instead of saving to cwd. Also ensures the parent directory exists before writing. Fixes #70 Co-Authored-By: Claude Opus 4.6 --- hate_crack/api.py | 21 ++++++++++++++---- tests/test_hashview.py | 50 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/hate_crack/api.py b/hate_crack/api.py index fb9909f..0fec8d8 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -918,7 +918,9 @@ class HashviewAPI: # 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: + with open( + found_hashes_file, "r", encoding="utf-8", errors="ignore" + ) as fhf: for line in fhf: line = line.strip() if line: @@ -930,16 +932,22 @@ class HashviewAPI: if potfile_path: appended = 0 with open(potfile_path, "a", encoding="utf-8") as pf: - with open(found_file, "r", encoding="utf-8", errors="ignore") as ff: + with open( + found_file, "r", encoding="utf-8", errors="ignore" + ) as ff: for line in ff: line = line.strip() if line and ":" in line: pf.write(line + "\n") appended += 1 combined_count = appended - print(f"✓ Appended {appended} found hashes to potfile: {potfile_path}") + print( + f"✓ Appended {appended} found hashes to potfile: {potfile_path}" + ) else: - print("Warning: No potfile path configured, skipping potfile update") + print( + "Warning: No potfile path configured, skipping potfile update" + ) # Clean up the two found_ files for f_path in (found_file, found_hashes_file, found_clears_file): @@ -1023,6 +1031,11 @@ class HashviewAPI: output_file = os.path.basename(match.group(1)) else: output_file = f"wordlist_{wordlist_id}.gz" + if not os.path.isabs(output_file): + dest_dir = get_hcat_wordlists_dir() + output_file = os.path.join(dest_dir, output_file) + os.makedirs(os.path.dirname(output_file), exist_ok=True) + total = int(resp.headers.get("content-length", 0)) downloaded = 0 chunk_size = 8192 diff --git a/tests/test_hashview.py b/tests/test_hashview.py index 8a1df3c..973d733 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -315,6 +315,52 @@ class TestHashviewAPI: assert "Cookie" in auth_headers or "uuid" in str(auth_headers) assert HASHVIEW_API_KEY in str(auth_headers) + def test_download_wordlist_saves_to_wordlists_dir(self, api, tmp_path): + """When output_file is relative, it should resolve to get_hcat_wordlists_dir().""" + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + + mock_response = Mock() + mock_response.content = b"gzipdata" + mock_response.raise_for_status = Mock() + mock_response.headers = { + "content-length": "8", + "content-disposition": 'attachment; filename="mylist.txt.gz"', + } + mock_response.iter_content = lambda chunk_size=8192: iter( + [mock_response.content] + ) + api.session.get.return_value = mock_response + + with patch( + "hate_crack.api.get_hcat_wordlists_dir", return_value=str(wordlists_dir) + ): + result = api.download_wordlist(99) + + expected_path = str(wordlists_dir / "mylist.txt.gz") + assert result["output_file"] == expected_path + assert os.path.exists(expected_path) + with open(expected_path, "rb") as f: + assert f.read() == b"gzipdata" + + def test_download_wordlist_absolute_path_unchanged(self, api, tmp_path): + """When output_file is absolute, it should not be redirected.""" + abs_output = str(tmp_path / "direct_output.gz") + + mock_response = Mock() + mock_response.content = b"data" + mock_response.raise_for_status = Mock() + mock_response.headers = {"content-length": "4"} + mock_response.iter_content = lambda chunk_size=8192: iter( + [mock_response.content] + ) + api.session.get.return_value = mock_response + + result = api.download_wordlist(99, output_file=abs_output) + + assert result["output_file"] == abs_output + assert os.path.exists(abs_output) + def test_list_wordlists_live(self): """Live test for Hashview wordlist listing with auth headers.""" # Only run this test if explicitly enabled @@ -600,7 +646,9 @@ class TestHashviewAPI: # Verify found files are cleaned up after merge found_file = tmp_path / "found_1_2.txt" - assert not os.path.exists(found_file), "Found file should be deleted after merge" + assert not os.path.exists(found_file), ( + "Found file should be deleted after merge" + ) found_hashes_file = tmp_path / "found_hashes_1_2.txt" found_clears_file = tmp_path / "found_clears_1_2.txt" From aaaa18543247fa7354f2c85b22890dccd1f0a9c4 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Tue, 17 Feb 2026 11:13:47 -0500 Subject: [PATCH 2/2] docs: update README Ollama defaults to match config Update ollamaModel from qwen2.5 to mistral and ollamaNumCtx from 8192 to 2048 to reflect recent config changes. Co-Authored-By: Claude Opus 4.6 --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4bc98fc..a792730 100644 --- a/README.md +++ b/README.md @@ -419,13 +419,13 @@ The LLM Attack (option 15) uses Ollama to generate password candidates. Configur ```json { - "ollamaModel": "qwen2.5", - "ollamaNumCtx": 8192 + "ollamaModel": "mistral", + "ollamaNumCtx": 2048 } ``` -- **`ollamaModel`** — The Ollama model to use for candidate generation (default: `qwen2.5`). -- **`ollamaNumCtx`** — Context window size for the model (default: `8192`). +- **`ollamaModel`** — The Ollama model to use for candidate generation (default: `mistral`). +- **`ollamaNumCtx`** — Context window size for the model (default: `2048`). - The Ollama URL defaults to `http://localhost:11434`. Ensure Ollama is running before using the LLM Attack. #### Automatic Found Hash Merging (Download Left Only)