diff --git a/.gitattributes b/.gitattributes index 20b04d1..0faf01d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,4 @@ /.travis.yml export-ignore /composer.lock export-ignore /phpunit.xml.dist export-ignore +/psalm.xml export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce6f607..b2eb5e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,5 +37,10 @@ jobs: - name: Code style checks run: ./vendor/bin/phpcs . + - name: Run psalm + if: ${{ matrix.php-versions == '7.4' }} + run: | + php vendor/bin/psalm --config=psalm.xml --no-progress + - name: Unit tests run: ./vendor/bin/phpunit diff --git a/composer.json b/composer.json index 2c40aaa..8769e2c 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "ext-json": "*" }, "require-dev": { + "vimeo/psalm": "4.*", "phpunit/phpunit": "^8.5", "php-coveralls/php-coveralls": "*", "squizlabs/php_codesniffer": "3.*" diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..cf6bfb0 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/src/Feedback.php b/src/Feedback.php index 2982e3d..4143524 100644 --- a/src/Feedback.php +++ b/src/Feedback.php @@ -16,8 +16,8 @@ class Feedback { /** * @param int $score - * @param MatchInterface[] $sequence - * @return array + * @param array $sequence + * @return array{warning: string, suggestions: array} */ public function getFeedback(int $score, array $sequence): array { @@ -42,7 +42,7 @@ public function getFeedback(int $score, array $sequence): array // tie feedback to the longest match for longer sequences $longestMatch = $sequence[0]; - foreach (array_slice($sequence, 1) as $match) { + foreach (\array_slice($sequence, 1) as $match) { if (mb_strlen($match->token) > mb_strlen($longestMatch->token)) { $longestMatch = $match; } diff --git a/src/Matcher.php b/src/Matcher.php index c843ab6..bc0f71a 100644 --- a/src/Matcher.php +++ b/src/Matcher.php @@ -20,17 +20,20 @@ class Matcher Matchers\YearMatch::class, ]; + /** + * @var array, class-string> + */ private $additionalMatchers = []; /** * Get matches for a password. * * @param string $password Password string to match - * @param array $userInputs Array of values related to the user (optional) + * @param array $userInputs Array of values related to the user (optional) * @code array('Alice Smith') * @endcode * - * @return MatchInterface[] Array of Match objects. + * @return BaseMatch[] * * @see zxcvbn/src/matching.coffee::omnimatch */ @@ -39,7 +42,7 @@ public function getMatches(string $password, array $userInputs = []): array $matches = []; foreach ($this->getMatchers() as $matcher) { $matched = $matcher::match($password, $userInputs); - if (is_array($matched) && !empty($matched)) { + if (!empty($matched)) { $matches[] = $matched; } } @@ -50,6 +53,11 @@ public function getMatches(string $password, array $userInputs = []): array return $matches; } + /** + * @param class-string $className + * + * @return $this + */ public function addMatcher(string $className): self { if (!is_a($className, MatchInterface::class, true)) { @@ -71,8 +79,9 @@ public function addMatcher(string $className): self * This function taken from https://github.com/vanderlee/PHP-stable-sort-functions * Copyright © 2015-2018 Martijn van der Lee (http://martijn.vanderlee.com). MIT License applies. * - * @param array $array - * @param callable $value_compare_func + * @template TSort + * @param array $array + * @param callable(TSort,TSort): int $value_compare_func * @return bool */ public static function usortStable(array &$array, callable $value_compare_func): bool @@ -81,13 +90,23 @@ public static function usortStable(array &$array, callable $value_compare_func): foreach ($array as &$item) { $item = [$index++, $item]; } - $result = usort($array, function ($a, $b) use ($value_compare_func) { - $result = $value_compare_func($a[1], $b[1]); - return $result == 0 ? $a[0] - $b[0] : $result; - }); + unset($item); + $result = usort( + $array, + /** + * @param array $a + * @param array $b + * @return int + */ + function ($a, $b) use ($value_compare_func) { + $result = $value_compare_func($a[1], $b[1]); + return $result == 0 ? $a[0] - $b[0] : $result; + } + ); foreach ($array as &$item) { $item = $item[1]; } + unset($item); return $result; } @@ -103,7 +122,7 @@ public static function compareMatches(BaseMatch $a, BaseMatch $b): int /** * Load available Match objects to match against a password. * - * @return array Array of classes implementing MatchInterface + * @return array> */ protected function getMatchers(): array { diff --git a/src/Matchers/BaseMatch.php b/src/Matchers/BaseMatch.php index 527dbc5..8028495 100644 --- a/src/Matchers/BaseMatch.php +++ b/src/Matchers/BaseMatch.php @@ -11,27 +11,27 @@ abstract class BaseMatch implements MatchInterface { /** - * @var + * @var string */ public $password; /** - * @var + * @var int */ public $begin; /** - * @var + * @var int */ public $end; /** - * @var + * @var string */ public $token; /** - * @var + * @var string */ public $pattern; @@ -46,9 +46,9 @@ public function __construct(string $password, int $begin, int $end, string $toke /** * Get feedback to a user based on the match. * - * @param bool $isSoleMatch + * @param bool $isSoleMatch * Whether this is the only match in the password - * @return array + * @return array{warning: string, suggestions: array} * Associative array with warning (string) and suggestions (array of strings) */ #[ArrayShape(['warning' => 'string', 'suggestions' => 'string[]'])] @@ -62,7 +62,7 @@ abstract public function getFeedback(bool $isSoleMatch): array; * @param string $regex * Regular expression with captures. * @param int $offset - * @return array + * @return array> * Array of capture groups. Captures in a group have named indexes: 'begin', 'end', 'token'. * e.g. fishfish /(fish)/ * array( @@ -82,7 +82,7 @@ public static function findAll(string $string, string $regex, int $offset = 0): // preg_match_all counts bytes, not characters: to correct this, we need to calculate the byte offset and pass // that in instead. $charsBeforeOffset = mb_substr($string, 0, $offset); - $byteOffset = strlen($charsBeforeOffset); + $byteOffset = \strlen($charsBeforeOffset); $count = preg_match_all($regex, $string, $matches, PREG_SET_ORDER, $byteOffset); if (!$count) { @@ -93,7 +93,7 @@ public static function findAll(string $string, string $regex, int $offset = 0): foreach ($matches as $group) { $captureBegin = 0; $match = array_shift($group); - $matchBegin = mb_strpos($string, $match, $offset); + $matchBegin = (int)mb_strpos($string, $match, $offset); $captures = [ [ 'begin' => $matchBegin, @@ -102,7 +102,7 @@ public static function findAll(string $string, string $regex, int $offset = 0): ], ]; foreach ($group as $capture) { - $captureBegin = mb_strpos($match, $capture, $captureBegin); + $captureBegin = (int)mb_strpos($match, $capture, $captureBegin); $captures[] = [ 'begin' => $matchBegin + $captureBegin, 'end' => $matchBegin + $captureBegin + mb_strlen($capture) - 1, diff --git a/src/Matchers/Bruteforce.php b/src/Matchers/Bruteforce.php index cba7bcc..c87efc4 100644 --- a/src/Matchers/Bruteforce.php +++ b/src/Matchers/Bruteforce.php @@ -21,7 +21,7 @@ class Bruteforce extends BaseMatch /** * @param string $password - * @param array $userInputs + * @param array $userInputs * @return Bruteforce[] */ public static function match(string $password, array $userInputs = []): array diff --git a/src/Matchers/DateMatch.php b/src/Matchers/DateMatch.php index f3fdcd9..570a8fb 100644 --- a/src/Matchers/DateMatch.php +++ b/src/Matchers/DateMatch.php @@ -20,6 +20,9 @@ class DateMatch extends BaseMatch public $pattern = 'date'; + /** + * @var array>> + */ private static $DATE_SPLITS = [ 4 => [ # For length-4 strings, eg 1191 or 9111, two ways to split: [1, 2], # 1 1 91 (2nd split starts at index 1, 3rd at index 2) @@ -57,23 +60,39 @@ class DateMatch extends BaseMatch */ protected const DATE_WITH_SEPARATOR = '/^(\d{1,4})([\s\/\\\\_.-])(\d{1,2})\2(\d{1,4})$/u'; - /** @var int The day portion of the date in the token. */ + /** + * The day portion of the date in the token. + * + * @var int + */ public $day; - /** @var int The month portion of the date in the token. */ + /** + * The month portion of the date in the token. + * + * @var int + */ public $month; - /** @var int The year portion of the date in the token. */ + /** + * The year portion of the date in the token. + * + * @var int + */ public $year; - /** @var string The separator used for the date in the token. */ + /** + * The separator used for the date in the token. + * + * @var string + */ public $separator; /** * Match occurences of dates in a password * * @param string $password - * @param array $userInputs + * @param array $userInputs * @return DateMatch[] */ public static function match(string $password, array $userInputs = []): array @@ -124,7 +143,7 @@ public function getFeedback(bool $isSoleMatch): array * @param int $begin * @param int $end * @param string $token - * @param array $params An array with keys: [day, month, year, separator]. + * @param array{day: int, month: int, year: int, separator: string} $params */ public function __construct(string $password, int $begin, int $end, string $token, array $params) { @@ -272,7 +291,7 @@ public static function getReferenceYear(): int /** * @param int[] $ints Three numbers in an array representing day, month and year (not necessarily in that order). - * @return array|bool Returns an associative array containing 'day', 'month' and 'year' keys, or false if the + * @return array{year: int, month: int, day: int}|false Returns an associative array containing 'day', 'month' and 'year' keys, or false if the * provided date array is invalid. */ protected static function checkDate(array $ints) @@ -319,7 +338,8 @@ protected static function checkDate(array $ints) foreach ($possibleYearSplits as [$year, $rest]) { if ($year >= static::MIN_YEAR && $year <= static::MAX_YEAR) { - if ($dm = static::mapIntsToDayMonth($rest)) { + $dm = static::mapIntsToDayMonth($rest); + if ($dm) { return [ 'year' => $year, 'month' => $dm['month'], @@ -334,7 +354,8 @@ protected static function checkDate(array $ints) } foreach ($possibleYearSplits as [$year, $rest]) { - if ($dm = static::mapIntsToDayMonth($rest)) { + $dm = static::mapIntsToDayMonth($rest); + if ($dm) { return [ 'year' => static::twoToFourDigitYear($year), 'month' => $dm['month'], @@ -348,7 +369,9 @@ protected static function checkDate(array $ints) /** * @param int[] $ints Two numbers in an array representing day and month (not necessarily in that order). - * @return array|bool Returns an associative array containing 'day' and 'month' keys, or false if any combination + * + * @return array{day: int, month: int}|false + * Returns an associative array containing 'day' and 'month' keys, or false if any combination * of the two numbers does not match a day and month. */ protected static function mapIntsToDayMonth(array $ints) diff --git a/src/Matchers/DictionaryMatch.php b/src/Matchers/DictionaryMatch.php index b936681..607797c 100644 --- a/src/Matchers/DictionaryMatch.php +++ b/src/Matchers/DictionaryMatch.php @@ -10,24 +10,39 @@ class DictionaryMatch extends BaseMatch { + /** + * @var string + */ public $pattern = 'dictionary'; - /** @var string The name of the dictionary that the token was found in. */ + /** + * @var string The name of the dictionary that the token was found in. + */ public $dictionaryName; - /** @var int The rank of the token in the dictionary. */ + /** + * @var int The rank of the token in the dictionary. + */ public $rank; - /** @var string The word that was matched from the dictionary. */ + /** + * @var string The word that was matched from the dictionary. + */ public $matchedWord; - /** @var bool Whether or not the matched word was reversed in the token. */ + /** + * @var bool Whether or not the matched word was reversed in the token. + */ public $reversed = false; - /** @var bool Whether or not the token contained l33t substitutions. */ + /** + * @var bool Whether or not the token contained l33t substitutions. + */ public $l33t = false; - /** @var array A cache of the frequency_lists json file */ + /** + * @var array A cache of the frequency_lists json file + */ protected static $rankedDictionaries = []; protected const START_UPPER = "/^[A-Z][^A-Z]+$/u"; @@ -39,7 +54,7 @@ class DictionaryMatch extends BaseMatch * Match occurrences of dictionary words in password. * * @param string $password - * @param array $userInputs + * @param array $userInputs * @param array $rankedDictionaries * @return DictionaryMatch[] */ @@ -75,7 +90,7 @@ public static function match(string $password, array $userInputs = [], array $ra * @param int $begin * @param int $end * @param string $token - * @param array $params An array with keys: [dictionary_name, matched_word, rank]. + * @param array{dictionary_name?: string, matched_word?: string, rank?: int} $params */ public function __construct(string $password, int $begin, int $end, string $token, array $params = []) { @@ -89,7 +104,7 @@ public function __construct(string $password, int $begin, int $end, string $toke /** * @param bool $isSoleMatch - * @return array + * @return array{suggestions: array, warning: string} */ #[ArrayShape(['warning' => 'string', 'suggestions' => 'string[]'])] public function getFeedback(bool $isSoleMatch): array @@ -186,7 +201,7 @@ protected static function dictionaryMatch(string $password, array $dict): array protected static function getRankedDictionaries(): array { if (empty(self::$rankedDictionaries)) { - $json = file_get_contents(dirname(__FILE__) . '/frequency_lists.json'); + $json = file_get_contents(__DIR__ . '/frequency_lists.json'); $data = json_decode($json, true); $rankedLists = []; diff --git a/src/Matchers/L33tMatch.php b/src/Matchers/L33tMatch.php index 6b6288b..f3d6098 100644 --- a/src/Matchers/L33tMatch.php +++ b/src/Matchers/L33tMatch.php @@ -14,20 +14,32 @@ */ class L33tMatch extends DictionaryMatch { - /** @var array An array of substitutions made to get from the token to the dictionary word. */ + /** + * An array of substitutions made to get from the token to the dictionary word. + * + * @var array + */ public $sub = []; - /** @var string A user-readable string that shows which substitutions were detected. */ + /** + * A user-readable string that shows which substitutions were detected. + * + * @var null|string + */ public $subDisplay; - /** @var bool Whether or not the token contained l33t substitutions. */ + /** + * Whether or not the token contained l33t substitutions. + * + * @var bool + */ public $l33t = true; /** * Match occurences of l33t words in password to dictionary words. * * @param string $password - * @param array $userInputs + * @param array $userInputs * @param array $rankedDictionaries * @return L33tMatch[] */ @@ -87,7 +99,7 @@ public static function match(string $password, array $userInputs = [], array $ra * @param int $begin * @param int $end * @param string $token - * @param array $params An array with keys: [sub, sub_display]. + * @param array|array{sub?: array, sub_display?: string} $params */ public function __construct(string $password, int $begin, int $end, string $token, array $params = []) { @@ -146,7 +158,7 @@ protected static function getL33tSubtable(string $password): array $table = static::getL33tTable(); foreach ($table as $letter => $substitutions) { foreach ($substitutions as $sub) { - if (in_array($sub, $passwordChars)) { + if (\in_array($sub, $passwordChars)) { $subTable[$letter][] = $sub; } } @@ -218,12 +230,28 @@ protected function getL33tVariations(): float foreach ($this->sub as $substitution => $letter) { $characters = preg_split('//u', mb_strtolower($this->token), -1, PREG_SPLIT_NO_EMPTY); - $subbed = count(array_filter($characters, function ($character) use ($substitution) { - return (string)$character === (string)$substitution; - })); - $unsubbed = count(array_filter($characters, function ($character) use ($letter) { - return (string)$character === (string)$letter; - })); + $subbed = count(array_filter( + $characters, + /** + * @param string $character + * @return bool + */ + function ($character) use ($substitution) { + /** @psalm-suppress RedundantCastGivenDocblockType | maybe it's needed here? */ + return (string)$character === (string)$substitution; + } + )); + $unsubbed = count(array_filter( + $characters, + /** + * @param string $character + * @return bool + */ + function ($character) use ($letter) { + /** @psalm-suppress RedundantCastGivenDocblockType | maybe it's needed here? */ + return (string)$character === (string)$letter; + } + )); if ($subbed === 0 || $unsubbed === 0) { // for this sub, password is either fully subbed (444) or fully unsubbed (aaa) diff --git a/src/Matchers/MatchInterface.php b/src/Matchers/MatchInterface.php index 24f1944..1f25c7d 100644 --- a/src/Matchers/MatchInterface.php +++ b/src/Matchers/MatchInterface.php @@ -10,11 +10,11 @@ interface MatchInterface * Match this password. * * @param string $password Password to check for match - * @param array $userInputs Array of values related to the user (optional) + * @param array $userInputs Array of values related to the user (optional) * @code array('Alice Smith') * @endcode * - * @return array|BaseMatch[] Array of Match objects + * @return BaseMatch[] */ public static function match(string $password, array $userInputs = []): array; diff --git a/src/Matchers/RepeatMatch.php b/src/Matchers/RepeatMatch.php index 3f38da2..039b064 100644 --- a/src/Matchers/RepeatMatch.php +++ b/src/Matchers/RepeatMatch.php @@ -16,23 +16,39 @@ class RepeatMatch extends BaseMatch public $pattern = 'repeat'; - /** @var MatchInterface[] An array of matches for the repeated section itself. */ + /** + * An array of matches for the repeated section itself. + * + * @var array + */ public $baseMatches = []; - /** @var int The number of guesses required for the repeated section itself. */ + /** + * The number of guesses required for the repeated section itself. + * + * @var float + */ public $baseGuesses; - /** @var int The number of times the repeated section is repeated. */ + /** + * The number of times the repeated section is repeated. + * + * @var int + */ public $repeatCount; - /** @var string The string that was repeated in the token. */ + /** + * The string that was repeated in the token. + * + * @var string + */ public $repeatedChar; /** * Match 3 or more repeated characters. * * @param string $password - * @param array $userInputs + * @param array $userInputs * @return RepeatMatch[] */ public static function match(string $password, array $userInputs = []): array @@ -64,7 +80,7 @@ public static function match(string $password, array $userInputs = []): array $baseMatches = $baseAnalysis['sequence']; $baseGuesses = $baseAnalysis['guesses']; - $repeatCount = mb_strlen($match[0]['token']) / mb_strlen($repeatedChar); + $repeatCount = (int)(mb_strlen($match[0]['token']) / mb_strlen($repeatedChar)); $matches[] = new static( $password, @@ -105,14 +121,14 @@ public function getFeedback(bool $isSoleMatch): array * @param int $begin * @param int $end * @param string $token - * @param array $params An array with keys: [repeated_char, base_guesses, base_matches, repeat_count]. + * @param array|array{repeated_char?: string, base_guesses?: float, base_marches?: array, repeat_count?: int} $params */ public function __construct(string $password, int $begin, int $end, string $token, array $params = []) { parent::__construct($password, $begin, $end, $token); if (!empty($params)) { $this->repeatedChar = $params['repeated_char'] ?? ''; - $this->baseGuesses = $params['base_guesses'] ?? 0; + $this->baseGuesses = $params['base_guesses'] ?? 0.0; $this->baseMatches = $params['base_matches'] ?? []; $this->repeatCount = $params['repeat_count'] ?? 0; } diff --git a/src/Matchers/ReverseDictionaryMatch.php b/src/Matchers/ReverseDictionaryMatch.php index c7b86b6..d1aa110 100644 --- a/src/Matchers/ReverseDictionaryMatch.php +++ b/src/Matchers/ReverseDictionaryMatch.php @@ -9,14 +9,18 @@ class ReverseDictionaryMatch extends DictionaryMatch { - /** @var bool Whether or not the matched word was reversed in the token. */ + /** + * Whether or not the matched word was reversed in the token. + * + * @var bool + */ public $reversed = true; /** * Match occurences of reversed dictionary words in password. * - * @param $password - * @param array $userInputs + * @param string $password + * @param array $userInputs * @param array $rankedDictionaries * @return ReverseDictionaryMatch[] */ diff --git a/src/Matchers/SequenceMatch.php b/src/Matchers/SequenceMatch.php index 1350f2a..cdd96a7 100644 --- a/src/Matchers/SequenceMatch.php +++ b/src/Matchers/SequenceMatch.php @@ -12,20 +12,32 @@ class SequenceMatch extends BaseMatch public $pattern = 'sequence'; - /** @var string The name of the detected sequence. */ + /** + * The name of the detected sequence. + * + * @var string + */ public $sequenceName; - /** @var int The number of characters in the complete sequence space. */ + /** + * The number of characters in the complete sequence space. + * + * @var int + */ public $sequenceSpace; - /** @var bool True if the sequence is ascending, and false if it is descending. */ + /** + * True if the sequence is ascending, and false if it is descending. + * + * @var bool + */ public $ascending; /** * Match sequences of three or more characters. * * @param string $password - * @param array $userInputs + * @param array $userInputs * @return SequenceMatch[] */ public static function match(string $password, array $userInputs = []): array @@ -54,11 +66,20 @@ public static function match(string $password, array $userInputs = []): array $lastDelta = $delta; } - static::findSequenceMatch($password, $begin, $passwordLength - 1, $lastDelta, $matches); + static::findSequenceMatch($password, $begin, $passwordLength - 1, (int)$lastDelta, $matches); return $matches; } + /** + * @param string $password + * @param int $begin + * @param int $end + * @param int $delta + * @param array $matches + * + * @return void + */ public static function findSequenceMatch(string $password, int $begin, int $end, int $delta, array &$matches) { if ($end - $begin > 1 || abs($delta) === 1) { @@ -120,7 +141,7 @@ protected function getRawGuesses(): float $firstCharacter = mb_substr($this->token, 0, 1); $guesses = 0; - if (in_array($firstCharacter, array('a', 'A', 'z', 'Z', '0', '1', '9'), true)) { + if (\in_array($firstCharacter, array('a', 'A', 'z', 'Z', '0', '1', '9'), true)) { $guesses += 4; // lower guesses for obvious starting points } elseif (ctype_digit($firstCharacter)) { $guesses += 10; // digits diff --git a/src/Matchers/SpatialMatch.php b/src/Matchers/SpatialMatch.php index 6de328b..00b21ff 100644 --- a/src/Matchers/SpatialMatch.php +++ b/src/Matchers/SpatialMatch.php @@ -20,23 +20,39 @@ class SpatialMatch extends BaseMatch public $pattern = 'spatial'; - /** @var int The number of characters the shift key was held for in the token. */ + /** + * The number of characters the shift key was held for in the token. + * + * @var int + */ public $shiftedCount; - /** @var int The number of turns on the keyboard required to complete the token. */ + /** + * The number of turns on the keyboard required to complete the token. + * + * @var int + */ public $turns; - /** @var string The keyboard layout that the token is a spatial match on. */ + /** + * The keyboard layout that the token is a spatial match on. + * + * @var string + */ public $graph; - /** @var array A cache of the adjacency_graphs json file */ + /** + * A cache of the adjacency_graphs json file. + * + * @var array|array{qwerty: array>, keypad: array>, mac_keypad: array>} + */ protected static $adjacencyGraphs = []; /** * Match spatial patterns based on keyboard layouts (e.g. qwerty, dvorak, keypad). * * @param string $password - * @param array $userInputs + * @param array $userInputs * @param array $graphs * @return SpatialMatch[] */ @@ -95,7 +111,7 @@ public function __construct(string $password, int $begin, int $end, string $toke * @param string $password * @param array $graph * @param string $graphName - * @return array + * @return array|array */ protected static function graphMatch(string $password, array $graph, string $graphName): array { @@ -201,12 +217,13 @@ protected static function indexOf(string $string, string $char): int public static function getAdjacencyGraphs(): array { if (empty(self::$adjacencyGraphs)) { - $json = file_get_contents(dirname(__FILE__) . '/adjacency_graphs.json'); + $json = file_get_contents(__DIR__ . '/adjacency_graphs.json'); $data = json_decode($json, true); // This seems pointless, but the data file is not guaranteed to be in any particular order. // We want to be in the exact order below so as to match most closely with upstream, because when a match // can be found in multiple graphs (such as 789), the one that's listed first is that one that will be picked. + /** @var array|array{qwerty: array>, keypad: array>, mac_keypad: array>} $data */ $data = [ 'qwerty' => $data['qwerty'], 'dvorak' => $data['dvorak'], diff --git a/src/Matchers/YearMatch.php b/src/Matchers/YearMatch.php index 9deeea6..987697d 100644 --- a/src/Matchers/YearMatch.php +++ b/src/Matchers/YearMatch.php @@ -12,13 +12,17 @@ class YearMatch extends BaseMatch public const NUM_YEARS = 119; public $pattern = 'regex'; + + /** + * @var string + */ public $regexName = 'recent_year'; /** * Match occurrences of years in a password * * @param string $password - * @param array $userInputs + * @param array $userInputs * @return YearMatch[] */ public static function match(string $password, array $userInputs = []): array @@ -46,6 +50,7 @@ public function getFeedback(bool $isSoleMatch): array protected function getRawGuesses(): float { + \assert(is_numeric($this->token)); $yearSpace = abs($this->token - DateMatch::getReferenceYear()); return max($yearSpace, DateMatch::MIN_YEAR_SPACE); } diff --git a/src/Math/Binomial.php b/src/Math/Binomial.php index 94e882f..e665b46 100644 --- a/src/Math/Binomial.php +++ b/src/Math/Binomial.php @@ -10,6 +10,9 @@ class Binomial { + /** + * @var null|BinomialProvider + */ private static $provider = null; private function __construct() @@ -39,13 +42,13 @@ public static function getProvider(): BinomialProvider } /** - * @return string[] + * @return array> */ public static function getUsableProviderClasses(): array { // In order of priority. The first provider with a value of true will be used. $possibleProviderClasses = [ - BinomialProviderPhp73Gmp::class => function_exists('gmp_binomial'), + BinomialProviderPhp73Gmp::class => \function_exists('gmp_binomial'), BinomialProviderInt64::class => PHP_INT_SIZE >= 8, BinomialProviderFloat64::class => PHP_FLOAT_DIG >= 15, ]; diff --git a/src/Scorer.php b/src/Scorer.php index 7cbd258..c9abff4 100644 --- a/src/Scorer.php +++ b/src/Scorer.php @@ -20,8 +20,19 @@ class Scorer public const MIN_SUBMATCH_GUESSES_SINGLE_CHAR = 10; public const MIN_SUBMATCH_GUESSES_MULTI_CHAR = 50; + /** + * @var string + */ protected $password; + + /** + * @var bool + */ protected $excludeAdditive; + + /** + * @var array|array{g: array>, m: array>, pi: array>} + */ protected $optimal = []; /** @@ -57,9 +68,9 @@ class Scorer * D^(l-1) approximates Sum(D^i for i in [1..l-1] * * @param string $password - * @param MatchInterface[] $matches + * @param array $matches * @param bool $excludeAdditive - * @return array Returns an array with these keys: [password, guesses, guesses_log10, sequence] + * @return array{password: string, guesses: float, guesses_log10: float, sequence: array} */ public function getMostGuessableMatchSequence(string $password, array $matches, bool $excludeAdditive = false): array { @@ -76,13 +87,19 @@ public function getMostGuessableMatchSequence(string $password, array $matches, } // small detail: for deterministic output, sort each sublist by i. - foreach ($matchesByEndIndex as &$matches) { - usort($matches, function ($a, $b) { - /** @var $a BaseMatch */ - /** @var $b BaseMatch */ - return $a->begin - $b->begin; - }); + foreach ($matchesByEndIndex as &$matchesInner) { + usort( + $matchesInner, + /** + * @param BaseMatch $a + * @param BaseMatch $b + */ + function ($a, $b) { + return $a->begin - $b->begin; + } + ); } + unset($matchesInner); $this->optimal = [ // optimal.m[k][l] holds final match in the best length-l match sequence covering the @@ -114,13 +131,13 @@ public function getMostGuessableMatchSequence(string $password, array $matches, $this->bruteforceUpdate($k); } - if ($length === 0) { $guesses = 1.0; $optimalSequence = []; } else { $optimalSequence = $this->unwind($length); $optimalSequenceLength = count($optimalSequence); + /** @psalm-suppress EmptyArrayAccess | $this->optimal was changed in other methods */ $guesses = $this->optimal['g'][$length - 1][$optimalSequenceLength]; } @@ -146,6 +163,8 @@ protected function update(BaseMatch $match, int $length): void // object-oriented approach we can just call getGuesses on the match directly. $pi = $match->getGuesses(); + \assert(empty($this->optimal['pi']) === false); + if ($length > 1) { // we're considering a length-l sequence ending with match m: // obtain the product term in the minimization function by multiplying m's guesses @@ -159,6 +178,8 @@ protected function update(BaseMatch $match, int $length): void $g += pow(self::MIN_GUESSES_BEFORE_GROWING_SEQUENCE, $length - 1); } + \assert(empty($this->optimal['g']) === false); + // update state if new best. // first see if any competing sequences covering this prefix, with l or fewer matches, // fare better than this sequence. if so, skip it and return. @@ -192,12 +213,15 @@ protected function bruteforceUpdate(int $end): void $match = $this->makeBruteforceMatch(0, $end); $this->update($match, 1); + \assert(empty($this->optimal['m']) === false); + // generate k bruteforce matches, spanning from (i=1, j=k) up to (i=k, j=k). // see if adding these new matches to any of the sequences in optimal[i-1] // leads to new bests. for ($i = 1; $i <= $end; $i++) { $match = $this->makeBruteforceMatch($i, $end); foreach ($this->optimal['m'][$i - 1] as $l => $lastM) { + /** @psalm-suppress RedundantCastGivenDocblockType | maybe it's needed here? */ $l = (int)$l; // corner: an optimal sequence will never have two adjacent bruteforce matches. @@ -227,7 +251,7 @@ protected function makeBruteforceMatch(int $begin, int $end): Bruteforce /** * helper: step backwards through optimal.m starting at the end, constructing the final optimal match sequence. * @param int $n - * @return MatchInterface[] + * @return array */ protected function unwind(int $n): array { @@ -238,6 +262,8 @@ protected function unwind(int $n): array $l = null; $g = INF; + \assert(empty($this->optimal['g']) === false); + foreach ($this->optimal['g'][$k] as $candidateL => $candidateG) { if ($candidateG < $g) { $l = $candidateL; @@ -245,7 +271,13 @@ protected function unwind(int $n): array } } + \assert(empty($this->optimal['m']) === false); + while ($k >= 0) { + /** + * @psalm-suppress PossiblyNullArrayOffset | try to add phpdoc for $this->optimal :/ + * @var BaseMatch $m + */ $m = $this->optimal['m'][$k][$l]; array_unshift($optimalSequence, $m); $k = $m->begin - 1; diff --git a/src/TimeEstimator.php b/src/TimeEstimator.php index bf67de9..ac1f40b 100644 --- a/src/TimeEstimator.php +++ b/src/TimeEstimator.php @@ -13,8 +13,23 @@ class TimeEstimator { /** - * @param int|float $guesses - * @return array + * @param float $guesses + * + * @return array{ + * crack_times_seconds: array{ + * online_throttling_100_per_hour: float, + * online_no_throttling_10_per_second: float, + * offline_slow_hashing_1e4_per_second: float, + * offline_fast_hashing_1e10_per_second: float + * }, + * crack_times_display: array{ + * online_throttling_100_per_hour: string, + * online_no_throttling_10_per_second: string, + * offline_slow_hashing_1e4_per_second: string, + * offline_fast_hashing_1e10_per_second: string + * }, + * score: int + * } */ public function estimateAttackTimes(float $guesses): array { diff --git a/src/Zxcvbn.php b/src/Zxcvbn.php index 697dd76..f3e17bd 100644 --- a/src/Zxcvbn.php +++ b/src/Zxcvbn.php @@ -12,22 +12,22 @@ class Zxcvbn { /** - * @var + * @var Matcher */ protected $matcher; /** - * @var + * @var Scorer */ protected $scorer; /** - * @var + * @var TimeEstimator */ protected $timeEstimator; /** - * @var + * @var Feedback */ protected $feedback; @@ -39,6 +39,11 @@ public function __construct() $this->feedback = new \ZxcvbnPhp\Feedback(); } + /** + * @param class-string<\ZxcvbnPhp\Matchers\BaseMatch> $className + * + * @return $this + */ public function addMatcher(string $className): self { $this->matcher->addMatcher($className); @@ -49,14 +54,10 @@ public function addMatcher(string $className): self /** * Calculate password strength via non-overlapping minimum entropy patterns. * - * @param string $password Password to measure - * @param array $userInputs Optional user inputs + * @param string $password Password to measure + * @param array $userInputs Optional user inputs * - * @return array Strength result array with keys: - * password - * entropy - * match_sequence - * score + * @return array{calc_time: float, feedback: array, guesses: float, guesses_log10: float, password: string, sequence: array} */ public function passwordStrength(string $password, array $userInputs = []): array { @@ -64,6 +65,7 @@ public function passwordStrength(string $password, array $userInputs = []): arra $sanitizedInputs = array_map( function ($input) { + /** @psalm-suppress RedundantCastGivenDocblockType | maybe it's needed here? */ return mb_strtolower((string) $input); }, $userInputs diff --git a/test/FeedbackTest.php b/test/FeedbackTest.php index 4140c87..98f02e4 100644 --- a/test/FeedbackTest.php +++ b/test/FeedbackTest.php @@ -12,7 +12,9 @@ class FeedbackTest extends TestCase { - /** @var Feedback */ + /** + * @var Feedback + */ private $feedback; public function setUp(): void diff --git a/test/Matchers/MockMatch.php b/test/Matchers/MockMatch.php index cf5d10b..d2e032f 100644 --- a/test/Matchers/MockMatch.php +++ b/test/Matchers/MockMatch.php @@ -9,7 +9,9 @@ class MockMatch extends BaseMatch { - /** @var float */ + /** + * @var float + */ protected $guesses; public function __construct(int $begin, int $end, float $guesses) @@ -44,7 +46,7 @@ public function getRawGuesses(): float * * @param string $password * Password to check for match. - * @param array $userInputs + * @param array $userInputs * Array of values related to the user (optional). * @code * array('Alice Smith') diff --git a/test/ScorerTest.php b/test/ScorerTest.php index 594e711..d48fbec 100644 --- a/test/ScorerTest.php +++ b/test/ScorerTest.php @@ -15,7 +15,9 @@ class ScorerTest extends TestCase { public const PASSWORD = '0123456789'; - /** @var Scorer */ + /** + * @var Scorer + */ private $scorer; public function setUp(): void diff --git a/test/TimeEstimatorTest.php b/test/TimeEstimatorTest.php index 1efca32..081e850 100644 --- a/test/TimeEstimatorTest.php +++ b/test/TimeEstimatorTest.php @@ -9,7 +9,9 @@ class TimeEstimatorTest extends TestCase { - /** @var TimeEstimator */ + /** + * @var TimeEstimator + */ private $timeEstimator; public function setUp(): void diff --git a/test/ZxcvbnTest.php b/test/ZxcvbnTest.php index 962ed49..3e8f78e 100644 --- a/test/ZxcvbnTest.php +++ b/test/ZxcvbnTest.php @@ -12,7 +12,9 @@ class ZxcvbnTest extends TestCase { - /** @var Zxcvbn */ + /** + * @var Zxcvbn + */ private $zxcvbn; public function setUp(): void