diff --git a/PROJECTS/beginner/hash-cracker/CMakeLists.txt b/PROJECTS/beginner/hash-cracker/CMakeLists.txt index 3ae70bba..94905d48 100644 --- a/PROJECTS/beginner/hash-cracker/CMakeLists.txt +++ b/PROJECTS/beginner/hash-cracker/CMakeLists.txt @@ -75,4 +75,6 @@ target_link_libraries(hashcracker_tests PRIVATE hashcracker_lib GTest::gtest_main ) include(GoogleTest) -gtest_discover_tests(hashcracker_tests) +gtest_discover_tests(hashcracker_tests + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} +) diff --git a/PROJECTS/beginner/hash-cracker/src/attack/BruteForceAttack.cpp b/PROJECTS/beginner/hash-cracker/src/attack/BruteForceAttack.cpp index 836a532b..b2c0532b 100644 --- a/PROJECTS/beginner/hash-cracker/src/attack/BruteForceAttack.cpp +++ b/PROJECTS/beginner/hash-cracker/src/attack/BruteForceAttack.cpp @@ -2,13 +2,64 @@ // BruteForceAttack.cpp #include "src/attack/BruteForceAttack.hpp" +#include -BruteForceAttack::BruteForceAttack(std::string_view, std::size_t, - unsigned, unsigned) {} - -std::expected BruteForceAttack::next() { - return std::unexpected(AttackComplete{}); +std::size_t BruteForceAttack::compute_keyspace(std::size_t charset_size, + std::size_t max_length) { + std::size_t total = 0; + std::size_t power = 1; + for (std::size_t len = 1; len <= max_length; ++len) { + power *= charset_size; + total += power; + } + return total; } -std::size_t BruteForceAttack::total() const { return total_; } -std::size_t BruteForceAttack::progress() const { return current_; } +BruteForceAttack::BruteForceAttack(std::string_view charset, + std::size_t max_length, + unsigned thread_index, + unsigned total_threads) + : charset_(charset), max_length_(max_length), + total_keyspace_(compute_keyspace(charset.size(), max_length)) { + std::size_t per_thread = total_keyspace_ / total_threads; + std::size_t remainder = total_keyspace_ % total_threads; + + start_index_ = thread_index * per_thread + + std::min(static_cast(thread_index), remainder); + std::size_t my_count = per_thread + (thread_index < remainder ? 1 : 0); + end_index_ = start_index_ + my_count; + current_index_ = start_index_; +} + +std::string BruteForceAttack::index_to_candidate(std::size_t index) const { + std::size_t base = charset_.size(); + std::size_t cumulative = 0; + std::size_t power = base; + + std::size_t length = 1; + while (cumulative + power <= index && length < max_length_) { + cumulative += power; + ++length; + power *= base; + } + + std::size_t offset = index - cumulative; + std::string result(length, charset_[0]); + + for (std::size_t i = length; i > 0; --i) { + result[i - 1] = charset_[offset % base]; + offset /= base; + } + + return result; +} + +std::expected BruteForceAttack::next() { + if (current_index_ >= end_index_) { + return std::unexpected(AttackComplete{}); + } + return index_to_candidate(current_index_++); +} + +std::size_t BruteForceAttack::total() const { return total_keyspace_; } +std::size_t BruteForceAttack::progress() const { return current_index_ - start_index_; } diff --git a/PROJECTS/beginner/hash-cracker/src/attack/BruteForceAttack.hpp b/PROJECTS/beginner/hash-cracker/src/attack/BruteForceAttack.hpp index 9919b010..b04aee75 100644 --- a/PROJECTS/beginner/hash-cracker/src/attack/BruteForceAttack.hpp +++ b/PROJECTS/beginner/hash-cracker/src/attack/BruteForceAttack.hpp @@ -19,6 +19,14 @@ public: std::size_t progress() const; private: - std::size_t total_ = 0; - std::size_t current_ = 0; + std::string charset_; + std::size_t max_length_; + std::size_t total_keyspace_; + std::size_t start_index_; + std::size_t end_index_; + std::size_t current_index_; + + std::string index_to_candidate(std::size_t index) const; + static std::size_t compute_keyspace(std::size_t charset_size, + std::size_t max_length); }; diff --git a/PROJECTS/beginner/hash-cracker/src/attack/DictionaryAttack.cpp b/PROJECTS/beginner/hash-cracker/src/attack/DictionaryAttack.cpp index 6cea48ca..45715b6d 100644 --- a/PROJECTS/beginner/hash-cracker/src/attack/DictionaryAttack.cpp +++ b/PROJECTS/beginner/hash-cracker/src/attack/DictionaryAttack.cpp @@ -2,15 +2,167 @@ // DictionaryAttack.cpp #include "src/attack/DictionaryAttack.hpp" +#include +#include +#include +#include +#include + +static std::size_t count_lines_in_range(const char* data, + std::size_t start, + std::size_t end) { + std::size_t count = 0; + for (std::size_t i = start; i < end; ++i) { + if (data[i] == '\n') { + ++count; + } + } + return count; +} + +static std::size_t find_next_newline(const char* data, + std::size_t pos, + std::size_t size) { + while (pos < size && data[pos] != '\n') { + ++pos; + } + return pos < size ? pos + 1 : size; +} std::expected DictionaryAttack::create( - std::string_view, unsigned, unsigned) { - return std::unexpected(CrackError::FileNotFound); + std::string_view path, unsigned thread_index, unsigned total_threads) { + std::string path_str(path); + int fd = open(path_str.c_str(), O_RDONLY); + if (fd < 0) { + return std::unexpected(CrackError::FileNotFound); + } + + struct stat sb{}; + if (fstat(fd, &sb) < 0) { + close(fd); + return std::unexpected(CrackError::FileNotFound); + } + + auto file_size = static_cast(sb.st_size); + if (file_size == 0) { + close(fd); + return std::unexpected(CrackError::InvalidConfig); + } + + auto* mapped = static_cast( + mmap(nullptr, file_size, PROT_READ, MAP_PRIVATE, fd, 0)); + + if (mapped == MAP_FAILED) { + close(fd); + return std::unexpected(CrackError::FileNotFound); + } + + madvise(const_cast(mapped), file_size, MADV_SEQUENTIAL); + + std::size_t total_lines = count_lines_in_range(mapped, 0, file_size); + if (file_size > 0 && mapped[file_size - 1] != '\n') { + ++total_lines; + } + + std::size_t lines_per_thread = total_lines / total_threads; + std::size_t remainder = total_lines % total_threads; + + std::size_t my_start_line = thread_index * lines_per_thread + + std::min(static_cast(thread_index), remainder); + std::size_t my_line_count = lines_per_thread + + (thread_index < remainder ? 1 : 0); + + std::size_t start_offset = 0; + for (std::size_t i = 0; i < my_start_line; ++i) { + start_offset = find_next_newline(mapped, start_offset, file_size); + } + + std::size_t end_offset = start_offset; + for (std::size_t i = 0; i < my_line_count; ++i) { + end_offset = find_next_newline(mapped, end_offset, file_size); + } + + DictionaryAttack attack; + attack.mapped_data_ = mapped; + attack.file_size_ = file_size; + attack.fd_ = fd; + attack.start_offset_ = start_offset; + attack.end_offset_ = end_offset; + attack.current_offset_ = start_offset; + attack.total_words_ = my_line_count; + attack.words_read_ = 0; + + return attack; +} + +DictionaryAttack::~DictionaryAttack() { + if (mapped_data_ && mapped_data_ != MAP_FAILED) { + munmap(const_cast(mapped_data_), file_size_); + } + if (fd_ >= 0) { + close(fd_); + } +} + +DictionaryAttack::DictionaryAttack(DictionaryAttack&& other) noexcept + : mapped_data_(other.mapped_data_), file_size_(other.file_size_), + fd_(other.fd_), start_offset_(other.start_offset_), + end_offset_(other.end_offset_), current_offset_(other.current_offset_), + total_words_(other.total_words_), words_read_(other.words_read_) { + other.mapped_data_ = nullptr; + other.fd_ = -1; +} + +DictionaryAttack& DictionaryAttack::operator=(DictionaryAttack&& other) noexcept { + if (this != &other) { + if (mapped_data_ && mapped_data_ != MAP_FAILED) { + munmap(const_cast(mapped_data_), file_size_); + } + if (fd_ >= 0) { + close(fd_); + } + + mapped_data_ = other.mapped_data_; + file_size_ = other.file_size_; + fd_ = other.fd_; + start_offset_ = other.start_offset_; + end_offset_ = other.end_offset_; + current_offset_ = other.current_offset_; + total_words_ = other.total_words_; + words_read_ = other.words_read_; + + other.mapped_data_ = nullptr; + other.fd_ = -1; + } + return *this; } std::expected DictionaryAttack::next() { - return std::unexpected(AttackComplete{}); + if (current_offset_ >= end_offset_) { + return std::unexpected(AttackComplete{}); + } + + std::size_t line_start = current_offset_; + std::size_t line_end = line_start; + + while (line_end < end_offset_ && mapped_data_[line_end] != '\n') { + ++line_end; + } + + std::size_t word_end = line_end; + if (word_end > line_start && mapped_data_[word_end - 1] == '\r') { + --word_end; + } + + current_offset_ = (line_end < end_offset_) ? line_end + 1 : end_offset_; + ++words_read_; + + if (word_end <= line_start) { + return next(); + } + + return std::string(mapped_data_ + line_start, word_end - line_start); } -std::size_t DictionaryAttack::total() const { return 0; } -std::size_t DictionaryAttack::progress() const { return 0; } +std::size_t DictionaryAttack::total() const { return total_words_; } +std::size_t DictionaryAttack::progress() const { return words_read_; } diff --git a/PROJECTS/beginner/hash-cracker/src/attack/DictionaryAttack.hpp b/PROJECTS/beginner/hash-cracker/src/attack/DictionaryAttack.hpp index b0c6573d..ef20edef 100644 --- a/PROJECTS/beginner/hash-cracker/src/attack/DictionaryAttack.hpp +++ b/PROJECTS/beginner/hash-cracker/src/attack/DictionaryAttack.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include "src/core/Concepts.hpp" class DictionaryAttack { @@ -14,10 +15,25 @@ public: static std::expected create( std::string_view path, unsigned thread_index, unsigned total_threads); + ~DictionaryAttack(); + DictionaryAttack(DictionaryAttack&& other) noexcept; + DictionaryAttack& operator=(DictionaryAttack&& other) noexcept; + std::expected next(); std::size_t total() const; std::size_t progress() const; private: DictionaryAttack() = default; + + const char* mapped_data_ = nullptr; + std::size_t file_size_ = 0; + int fd_ = -1; + + std::size_t start_offset_ = 0; + std::size_t end_offset_ = 0; + std::size_t current_offset_ = 0; + + std::size_t total_words_ = 0; + std::size_t words_read_ = 0; }; diff --git a/PROJECTS/beginner/hash-cracker/src/attack/RuleAttack.cpp b/PROJECTS/beginner/hash-cracker/src/attack/RuleAttack.cpp index 9055de89..4d33df82 100644 --- a/PROJECTS/beginner/hash-cracker/src/attack/RuleAttack.cpp +++ b/PROJECTS/beginner/hash-cracker/src/attack/RuleAttack.cpp @@ -2,15 +2,48 @@ // RuleAttack.cpp #include "src/attack/RuleAttack.hpp" +#include "src/rules/RuleSet.hpp" + +RuleAttack::RuleAttack(DictionaryAttack dict, bool chain_rules) + : dict_(std::move(dict)), chain_rules_(chain_rules) {} std::expected RuleAttack::create( - std::string_view, bool, unsigned, unsigned) { - return std::unexpected(CrackError::FileNotFound); + std::string_view path, bool chain_rules, + unsigned thread_index, unsigned total_threads) { + auto dict = DictionaryAttack::create(path, thread_index, total_threads); + if (!dict.has_value()) { + return std::unexpected(dict.error()); + } + return RuleAttack(std::move(*dict), chain_rules); +} + +bool RuleAttack::load_next_word() { + auto word = dict_.next(); + if (!word.has_value()) { + return false; + } + + mutations_.clear(); + mutations_.push_back(*word); + + for (auto&& m : RuleSet::apply_all(*word)) { + mutations_.push_back(std::move(m)); + } + + mutation_index_ = 0; + return true; } std::expected RuleAttack::next() { - return std::unexpected(AttackComplete{}); + while (mutation_index_ >= mutations_.size()) { + if (!load_next_word()) { + return std::unexpected(AttackComplete{}); + } + } + + ++candidates_yielded_; + return std::move(mutations_[mutation_index_++]); } -std::size_t RuleAttack::total() const { return 0; } -std::size_t RuleAttack::progress() const { return 0; } +std::size_t RuleAttack::total() const { return dict_.total(); } +std::size_t RuleAttack::progress() const { return candidates_yielded_; } diff --git a/PROJECTS/beginner/hash-cracker/src/attack/RuleAttack.hpp b/PROJECTS/beginner/hash-cracker/src/attack/RuleAttack.hpp index 7718495b..f9379b0e 100644 --- a/PROJECTS/beginner/hash-cracker/src/attack/RuleAttack.hpp +++ b/PROJECTS/beginner/hash-cracker/src/attack/RuleAttack.hpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include "src/attack/DictionaryAttack.hpp" #include "src/core/Concepts.hpp" class RuleAttack { @@ -20,5 +22,13 @@ public: std::size_t progress() const; private: - RuleAttack() = default; + RuleAttack(DictionaryAttack dict, bool chain_rules); + + DictionaryAttack dict_; + bool chain_rules_; + std::vector mutations_; + std::size_t mutation_index_ = 0; + std::size_t candidates_yielded_ = 0; + + bool load_next_word(); }; diff --git a/PROJECTS/beginner/hash-cracker/src/rules/RuleSet.cpp b/PROJECTS/beginner/hash-cracker/src/rules/RuleSet.cpp index 24be5881..a1acd7e3 100644 --- a/PROJECTS/beginner/hash-cracker/src/rules/RuleSet.cpp +++ b/PROJECTS/beginner/hash-cracker/src/rules/RuleSet.cpp @@ -2,12 +2,77 @@ // RuleSet.cpp #include "src/rules/RuleSet.hpp" +#include "src/config/Config.hpp" +#include +#include +#include -std::generator RuleSet::capitalize_first(std::string_view) { co_return; } -std::generator RuleSet::uppercase_all(std::string_view) { co_return; } -std::generator RuleSet::leet_speak(std::string_view) { co_return; } -std::generator RuleSet::append_digits(std::string_view) { co_return; } -std::generator RuleSet::prepend_digits(std::string_view) { co_return; } -std::generator RuleSet::reverse(std::string_view) { co_return; } -std::generator RuleSet::toggle_case(std::string_view) { co_return; } -std::generator RuleSet::apply_all(std::string_view) { co_return; } +static const std::unordered_map LEET_MAP = { + {'a', '@'}, {'e', '3'}, {'i', '1'}, + {'o', '0'}, {'s', '$'}, {'t', '7'} +}; + +std::generator RuleSet::capitalize_first(std::string_view word) { + if (word.empty()) { co_return; } + std::string result(word); + result[0] = static_cast(std::toupper(static_cast(result[0]))); + co_yield std::move(result); +} + +std::generator RuleSet::uppercase_all(std::string_view word) { + std::string result(word); + std::ranges::transform(result, result.begin(), [](unsigned char c) { + return static_cast(std::toupper(c)); + }); + co_yield std::move(result); +} + +std::generator RuleSet::leet_speak(std::string_view word) { + std::string result(word); + for (auto& c : result) { + auto it = LEET_MAP.find(static_cast( + std::tolower(static_cast(c)))); + if (it != LEET_MAP.end()) { + c = it->second; + } + } + co_yield std::move(result); +} + +std::generator RuleSet::append_digits(std::string_view word) { + std::string base(word); + for (std::size_t i = 0; i <= config::MAX_APPEND_DIGIT; ++i) { + co_yield base + std::to_string(i); + } +} + +std::generator RuleSet::prepend_digits(std::string_view word) { + std::string base(word); + for (std::size_t i = 0; i <= config::MAX_PREPEND_DIGIT; ++i) { + co_yield std::to_string(i) + base; + } +} + +std::generator RuleSet::reverse(std::string_view word) { + std::string result(word.rbegin(), word.rend()); + co_yield std::move(result); +} + +std::generator RuleSet::toggle_case(std::string_view word) { + std::string result(word); + std::ranges::transform(result, result.begin(), [](unsigned char c) { + if (std::islower(c)) { return static_cast(std::toupper(c)); } + return static_cast(std::tolower(c)); + }); + co_yield std::move(result); +} + +std::generator RuleSet::apply_all(std::string_view word) { + for (auto&& s : capitalize_first(word)) { co_yield std::move(s); } + for (auto&& s : uppercase_all(word)) { co_yield std::move(s); } + for (auto&& s : leet_speak(word)) { co_yield std::move(s); } + for (auto&& s : append_digits(word)) { co_yield std::move(s); } + for (auto&& s : prepend_digits(word)) { co_yield std::move(s); } + for (auto&& s : reverse(word)) { co_yield std::move(s); } + for (auto&& s : toggle_case(word)) { co_yield std::move(s); } +} diff --git a/PROJECTS/beginner/hash-cracker/tests/data/small_wordlist.txt b/PROJECTS/beginner/hash-cracker/tests/data/small_wordlist.txt new file mode 100644 index 00000000..b4b12c54 --- /dev/null +++ b/PROJECTS/beginner/hash-cracker/tests/data/small_wordlist.txt @@ -0,0 +1,10 @@ +password +123456 +qwerty +letmein +dragon +master +monkey +shadow +sunshine +trustno1 diff --git a/PROJECTS/beginner/hash-cracker/tests/test_bruteforce_attack.cpp b/PROJECTS/beginner/hash-cracker/tests/test_bruteforce_attack.cpp index 5c85810a..8bf83562 100644 --- a/PROJECTS/beginner/hash-cracker/tests/test_bruteforce_attack.cpp +++ b/PROJECTS/beginner/hash-cracker/tests/test_bruteforce_attack.cpp @@ -2,7 +2,54 @@ // test_bruteforce_attack.cpp #include +#include "src/attack/BruteForceAttack.hpp" +#include +#include -TEST(BruteForceAttackTest, Stub) { - EXPECT_TRUE(true); +TEST(BruteForceAttackTest, GeneratesAllSingleCharCombinations) { + BruteForceAttack attack("ab", 1, 0, 1); + std::vector results; + while (auto candidate = attack.next()) { + results.push_back(std::move(*candidate)); + } + EXPECT_EQ(results.size(), 2); + EXPECT_EQ(results[0], "a"); + EXPECT_EQ(results[1], "b"); +} + +TEST(BruteForceAttackTest, GeneratesUpToMaxLength) { + BruteForceAttack attack("ab", 2, 0, 1); + std::vector results; + while (auto candidate = attack.next()) { + results.push_back(std::move(*candidate)); + } + EXPECT_EQ(results.size(), 6); +} + +TEST(BruteForceAttackTest, PartitionsKeyspace) { + BruteForceAttack p0("ab", 2, 0, 2); + BruteForceAttack p1("ab", 2, 1, 2); + std::set all; + while (auto c = p0.next()) { all.insert(*c); } + while (auto c = p1.next()) { all.insert(*c); } + EXPECT_EQ(all.size(), 6); +} + +TEST(BruteForceAttackTest, ReportsCorrectTotal) { + BruteForceAttack attack("abc", 3, 0, 1); + EXPECT_EQ(attack.total(), 3 + 9 + 27); +} + +TEST(BruteForceAttackTest, GeneratesCorrectTwoCharCombinations) { + BruteForceAttack attack("ab", 2, 0, 1); + std::set results; + while (auto candidate = attack.next()) { + results.insert(*candidate); + } + EXPECT_TRUE(results.contains("a")); + EXPECT_TRUE(results.contains("b")); + EXPECT_TRUE(results.contains("aa")); + EXPECT_TRUE(results.contains("ab")); + EXPECT_TRUE(results.contains("ba")); + EXPECT_TRUE(results.contains("bb")); } diff --git a/PROJECTS/beginner/hash-cracker/tests/test_dictionary_attack.cpp b/PROJECTS/beginner/hash-cracker/tests/test_dictionary_attack.cpp index 50df97f4..64ef8c9a 100644 --- a/PROJECTS/beginner/hash-cracker/tests/test_dictionary_attack.cpp +++ b/PROJECTS/beginner/hash-cracker/tests/test_dictionary_attack.cpp @@ -2,7 +2,41 @@ // test_dictionary_attack.cpp #include +#include "src/attack/DictionaryAttack.hpp" +#include -TEST(DictionaryAttackTest, Stub) { - EXPECT_TRUE(true); +TEST(DictionaryAttackTest, ReadsAllWords) { + auto attack = DictionaryAttack::create("tests/data/small_wordlist.txt", 0, 1); + ASSERT_TRUE(attack.has_value()); + + std::vector words; + while (auto word = attack->next()) { + words.push_back(std::move(*word)); + } + EXPECT_EQ(words.size(), 10); + EXPECT_EQ(words.front(), "password"); + EXPECT_EQ(words.back(), "trustno1"); +} + +TEST(DictionaryAttackTest, PartitionsAcrossThreads) { + auto p0 = DictionaryAttack::create("tests/data/small_wordlist.txt", 0, 2); + auto p1 = DictionaryAttack::create("tests/data/small_wordlist.txt", 1, 2); + ASSERT_TRUE(p0.has_value()); + ASSERT_TRUE(p1.has_value()); + + std::size_t count0 = 0, count1 = 0; + while (p0->next()) { ++count0; } + while (p1->next()) { ++count1; } + EXPECT_EQ(count0 + count1, 10); +} + +TEST(DictionaryAttackTest, ReportsTotal) { + auto attack = DictionaryAttack::create("tests/data/small_wordlist.txt", 0, 1); + ASSERT_TRUE(attack.has_value()); + EXPECT_EQ(attack->total(), 10); +} + +TEST(DictionaryAttackTest, MissingFileReturnsError) { + auto attack = DictionaryAttack::create("nonexistent.txt", 0, 1); + EXPECT_FALSE(attack.has_value()); } diff --git a/PROJECTS/beginner/hash-cracker/tests/test_rules.cpp b/PROJECTS/beginner/hash-cracker/tests/test_rules.cpp index 14cd8fe3..90ac4119 100644 --- a/PROJECTS/beginner/hash-cracker/tests/test_rules.cpp +++ b/PROJECTS/beginner/hash-cracker/tests/test_rules.cpp @@ -2,7 +2,80 @@ // test_rules.cpp #include +#include "src/attack/RuleAttack.hpp" +#include "src/rules/RuleSet.hpp" +#include +#include -TEST(RuleSetTest, Stub) { - EXPECT_TRUE(true); +static std::vector collect(std::generator gen) { + std::vector out; + for (auto&& s : gen) { + out.push_back(std::move(s)); + } + return out; +} + +TEST(RuleSetTest, CapitalizeFirst) { + auto results = collect(RuleSet::capitalize_first("password")); + ASSERT_EQ(results.size(), 1); + EXPECT_EQ(results[0], "Password"); +} + +TEST(RuleSetTest, UppercaseAll) { + auto results = collect(RuleSet::uppercase_all("password")); + ASSERT_EQ(results.size(), 1); + EXPECT_EQ(results[0], "PASSWORD"); +} + +TEST(RuleSetTest, LeetSpeak) { + auto results = collect(RuleSet::leet_speak("password")); + ASSERT_EQ(results.size(), 1); + EXPECT_EQ(results[0], "p@$$w0rd"); +} + +TEST(RuleSetTest, AppendDigits) { + auto results = collect(RuleSet::append_digits("pass")); + EXPECT_EQ(results.size(), 1000); + EXPECT_EQ(results[0], "pass0"); + EXPECT_EQ(results[999], "pass999"); +} + +TEST(RuleSetTest, PrependDigits) { + auto results = collect(RuleSet::prepend_digits("pass")); + EXPECT_EQ(results.size(), 1000); + EXPECT_EQ(results[0], "0pass"); +} + +TEST(RuleSetTest, Reverse) { + auto results = collect(RuleSet::reverse("password")); + ASSERT_EQ(results.size(), 1); + EXPECT_EQ(results[0], "drowssap"); +} + +TEST(RuleSetTest, ToggleCase) { + auto results = collect(RuleSet::toggle_case("password")); + ASSERT_EQ(results.size(), 1); + EXPECT_EQ(results[0], "PASSWORD"); +} + +TEST(RuleSetTest, AllRulesProduceMutations) { + auto all = collect(RuleSet::apply_all("password")); + EXPECT_GT(all.size(), 2000); + EXPECT_TRUE(std::ranges::find(all, "Password") != all.end()); + EXPECT_TRUE(std::ranges::find(all, "p@$$w0rd") != all.end()); + EXPECT_TRUE(std::ranges::find(all, "password0") != all.end()); +} + +TEST(RuleAttackTest, AppliesRulesToDictionaryWords) { + auto attack = RuleAttack::create("tests/data/small_wordlist.txt", false, 0, 1); + ASSERT_TRUE(attack.has_value()); + + std::vector candidates; + while (auto c = attack->next()) { + candidates.push_back(std::move(*c)); + } + EXPECT_GT(candidates.size(), 10); + EXPECT_TRUE(std::ranges::find(candidates, "Password") != candidates.end()); + EXPECT_TRUE(std::ranges::find(candidates, "p@$$w0rd") != candidates.end()); + EXPECT_TRUE(std::ranges::find(candidates, "password") != candidates.end()); }