Completed
Push — master ( c6ec0f...bab290 )
by Magnar Ovedal
03:36
created

Dictionary::getWholeWordsToCheck()   A

Complexity

Conditions 5
Paths 3

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 5

Importance

Changes 0
Metric Value
cc 5
eloc 4
nc 3
nop 1
dl 0
loc 7
ccs 5
cts 5
cp 1
crap 5
rs 9.6111
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Stadly\PasswordPolice\Rule;
6
7
use InvalidArgumentException;
8
use RuntimeException;
9
use Traversable;
10
use Stadly\PasswordPolice\Policy;
11
use Stadly\PasswordPolice\WordConverter\WordConverterInterface;
12
use Stadly\PasswordPolice\WordList\WordListInterface;
13
14
final class Dictionary implements RuleInterface
15
{
16
    /**
17
     * @var WordListInterface Word list for the dictionary.
18
     */
19
    private $wordList;
20
21
    /**
22
     * @var int Minimum word length to consider.
23
     */
24
    private $minWordLength;
25
26
    /**
27
     * @var int|null Maximum word length to consider.
28
     */
29
    private $maxWordLength;
30
31
    /**
32
     * @var bool Whether all substrings of the password should be checked.
33
     */
34
    private $checkSubstrings;
35
36
    /**
37
     * @var WordConverterInterface[] Word converters.
38
     */
39
    private $wordConverters;
40
41
    /**
42
     * @param WordListInterface $wordList Word list for the dictionary.
43
     * @param int $minWordLength Ignore words shorter than this.
44
     * @param int|null $maxWordLength Ignore words longer than this.
45
     * @param bool $checkSubstrings Check all substrings of the password, not just the whole password.
46
     * @param WordConverterInterface... $wordConverters Word converters.
0 ignored issues
show
Documentation Bug introduced by
The doc comment WordConverterInterface... at position 0 could not be parsed: Unknown type name 'WordConverterInterface...' at position 0 in WordConverterInterface....
Loading history...
47
     */
48 7
    public function __construct(
49
        WordListInterface $wordList,
50
        int $minWordLength = 3,
51
        ?int $maxWordLength = 25,
52
        bool $checkSubstrings = true,
53
        WordConverterInterface... $wordConverters
54
    ) {
55 7
        if ($minWordLength < 1) {
56 2
            throw new InvalidArgumentException('Minimum word length must be positive.');
57
        }
58 5
        if ($maxWordLength !== null && $maxWordLength < $minWordLength) {
59 1
            throw new InvalidArgumentException('Maximum word length cannot be smaller than mininum word length.');
60
        }
61
62 4
        $this->wordList = $wordList;
63 4
        $this->minWordLength = $minWordLength;
64 4
        $this->maxWordLength = $maxWordLength;
65 4
        $this->checkSubstrings = $checkSubstrings;
66 4
        $this->wordConverters = $wordConverters;
67 4
    }
68
69
    /**
70
     * @return WordListInterface Word list for the dictionary.
71
     */
72 1
    public function getWordList(): WordListInterface
73
    {
74 1
        return $this->wordList;
75
    }
76
77
    /**
78
     * @return int Minimum word length to consider.
79
     */
80 1
    public function getMinWordLength(): int
81
    {
82 1
        return $this->minWordLength;
83
    }
84
85
    /**
86
     * @return int|null Maximum word length to consider.
87
     */
88 1
    public function getMaxWordLength(): ?int
89
    {
90 1
        return $this->maxWordLength;
91
    }
92
93
    /**
94
     * {@inheritDoc}
95
     */
96 19
    public function test($password): bool
97
    {
98 19
        $word = $this->getDictionaryWord((string)$password);
99
100 18
        return $word === null;
101
    }
102
103
    /**
104
     * {@inheritDoc}
105
     */
106 2
    public function enforce($password): void
107
    {
108 2
        $word = $this->getDictionaryWord((string)$password);
109
110 2
        if ($word !== null) {
111 1
            throw new RuleException($this, $this->getMessage());
112
        }
113 1
    }
114
115
    /**
116
     * @param string $password Password to find dictionary words in.
117
     * @return string|null Dictionary word in the password.
118
     * @throws TestException If an error occurred while using the word list.
119
     */
120 21
    private function getDictionaryWord(string $password): ?string
121
    {
122 21
        foreach ($this->getWordsToCheck($password) as $word) {
123
            try {
124 18
                if ($this->wordList->contains($word)) {
0 ignored issues
show
Bug introduced by
$word of type Generator is incompatible with the type string expected by parameter $word of Stadly\PasswordPolice\Wo...stInterface::contains(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

124
                if ($this->wordList->contains(/** @scrutinizer ignore-type */ $word)) {
Loading history...
125 17
                    return $word;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $word returns the type Generator which is incompatible with the type-hinted return null|string.
Loading history...
126
                }
127 1
            } catch (RuntimeException $exception) {
128 13
                throw new TestException($this, 'An error occurred while using the word list.', $exception);
129
            }
130
        }
131 11
        return null;
132
    }
133
134
    /**
135
     * @param string $word Word to check.
136
     * @return Traversable<string> Variants of the word to check.
137
     */
138 21
    private function getWordsToCheck($word): Traversable
139
    {
140 21
        $convertedWords = $this->getUniqueWords($this->getConvertedWords($word));
141
142 21
        if ($this->checkSubstrings) {
143 12
            $wordsToCheck = $this->getUniqueWords($this->getSubstringWordsToCheck($convertedWords));
144
        } else {
145 9
            $wordsToCheck = $this->getWholeWordsToCheck($convertedWords);
146
        }
147
148 21
        yield from $wordsToCheck;
149 11
    }
150
151
    /**
152
     * @param Traversable<string> $words Words to filter.
153
     * @return Traversable<string> Unique words.
154
     */
155 21
    private function getUniqueWords(Traversable $words): Traversable
156
    {
157 21
        $checked = [];
158 21
        foreach ($words as $word) {
159 21
            if (isset($checked[$word])) {
160 5
                continue;
161
            }
162
163 21
            $checked[$word] = true;
164 21
            yield $word;
165
        }
166 11
    }
167
168
    /**
169
     * @param string $word Word to convert.
170
     * @return Traversable<string> Converted words. May contain duplicates.
171
     */
172 21
    private function getConvertedWords(string $word): Traversable
173
    {
174 21
        yield $word;
175
176 12
        foreach ($this->wordConverters as $wordConverter) {
177 4
            foreach ($wordConverter->convert($word) as $converted) {
178 4
                yield $converted;
179
            }
180
        }
181 11
    }
182
183
    /**
184
     * @param Traversable<string> $words Words to check.
185
     * @return Traversable<string> Whole words to check.
186
     */
187 9
    private function getWholeWordsToCheck(Traversable $words): Traversable
188
    {
189 9
        foreach ($words as $word) {
190 9
            if ($this->minWordLength <= mb_strlen($word) &&
191 9
               ($this->maxWordLength === null || mb_strlen($word) <= $this->maxWordLength)
192
            ) {
193 9
                yield $word;
194
            }
195
        }
196 7
    }
197
198
    /**
199
     * @param Traversable<string> $words Words to check.
200
     * @return Traversable<string> Substring words to check.
201
     */
202 12
    private function getSubstringWordsToCheck(Traversable $words): Traversable
203
    {
204 12
        foreach ($words as $word) {
205 12
            for ($start = 0; $start < mb_strlen($word); ++$start) {
206 12
                $substring = mb_substr($word, $start, $this->maxWordLength);
207
208 12
                for ($wordLength = mb_strlen($substring); $this->minWordLength <= $wordLength; --$wordLength) {
209 11
                    yield mb_substr($substring, 0, $wordLength);
210
                }
211
            }
212
        }
213 4
    }
214
215
    /**
216
     * {@inheritDoc}
217
     */
218 2
    public function getMessage(): string
219
    {
220 2
        $translator = Policy::getTranslator();
221
222 2
        if ($this->checkSubstrings) {
223 1
            return $translator->trans(
224 1
                'Must not contain dictionary words.'
225
            );
226
        } else {
227 1
            return $translator->trans(
228 1
                'Must not be a dictionary word.'
229
            );
230
        }
231
    }
232
}
233