zxcvbn-ruby 1.2.4 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52ddbaaabcb2929e59d34d91104bce81b86f223428001218872294363d79f2ac
4
- data.tar.gz: 5c488ab8d0dfbd6c46b2b5b05f3b878355e09babe09304c3653c9e45dcf64d02
3
+ metadata.gz: 36aa566fe4268e91239c2232628e3eb7397cdf06c99608efa63670842a5b5b4c
4
+ data.tar.gz: 8c736d0a2f84507600e9ba3da51a368f056c2e8918672238415f636bffcd6ca3
5
5
  SHA512:
6
- metadata.gz: 6ac904b4f3e4219981def358d1f7aa69870e9e33605c3696123e95f936d4880b39d2b5e1d5f323fd89ebc7b2ff43eaa93f22c598ac40ea20bdd768072a082162
7
- data.tar.gz: f32d688a3ee53867c1f47f0e52527d3da4397e26c93854305ee5cbcf224e9dad104f087ab80174848b3765dd41a086a6fd33a4ab72a1b72d8ea05209aa88b289
6
+ metadata.gz: f569dc2cee3a3eee7c8b1adad5f924d74c0b5d2aac11eb61ec4e3330f3043f89d5543a74f073b508b0820bde71e0473b7e096c4d10138fb459fd90dcd7461667
7
+ data.tar.gz: f5873e8cdf377c6e30097025c5e8b3c02a4a8c65da2fd2051ef14791ee0123a8224e1c1627a0559338e1e16aa6808e1691afd3d00515057ba79732c62737247a
data/CHANGELOG.md CHANGED
@@ -6,7 +6,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- [Unreleased]: https://github.com/envato/zxcvbn-ruby/compare/v1.2.4...HEAD
9
+ [Unreleased]: https://github.com/envato/zxcvbn-ruby/compare/v1.3.0...HEAD
10
+
11
+ ## [1.3.0] - 2026-01-02
12
+
13
+ ### Changed
14
+ - Replace OpenStruct with regular class in `Zxcvbn::Match` for 2x performance improvement ([#61])
15
+ - Implement Trie data structure for dictionary matching with 1.4x additional performance improvement ([#62])
16
+ - Replace range operators with `String#slice` for string slicing operations ([#63])
17
+ - Optimise L33t matcher with early bailout and improved deduplication ([#64])
18
+ - Pre-compute spatial graph statistics during data initialisation ([#65])
19
+ - Optimise nCk calculation using symmetry property ([#66])
20
+
21
+ Overall performance improvement: 4.1x faster than v1.2.4 (0.722ms → 0.176ms per password)
22
+
23
+ [1.3.0]: https://github.com/envato/zxcvbn-ruby/compare/v1.2.4...v1.3.0
24
+ [#61]: https://github.com/envato/zxcvbn-ruby/pull/61
25
+ [#62]: https://github.com/envato/zxcvbn-ruby/pull/62
26
+ [#63]: https://github.com/envato/zxcvbn-ruby/pull/63
27
+ [#64]: https://github.com/envato/zxcvbn-ruby/pull/64
28
+ [#65]: https://github.com/envato/zxcvbn-ruby/pull/65
29
+ [#66]: https://github.com/envato/zxcvbn-ruby/pull/66
10
30
 
11
31
  ## [1.2.4] - 2025-12-07
12
32
 
data/lib/zxcvbn/data.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'zxcvbn/dictionary_ranker'
5
+ require 'zxcvbn/trie'
5
6
 
6
7
  module Zxcvbn
7
8
  class Data
@@ -14,12 +15,16 @@ module Zxcvbn
14
15
  'surnames' => read_word_list('surnames.txt')
15
16
  )
16
17
  @adjacency_graphs = JSON.parse(DATA_PATH.join('adjacency_graphs.json').read)
18
+ @dictionary_tries = build_tries
19
+ @graph_stats = compute_graph_stats
17
20
  end
18
21
 
19
- attr_reader :ranked_dictionaries, :adjacency_graphs
22
+ attr_reader :ranked_dictionaries, :adjacency_graphs, :dictionary_tries, :graph_stats
20
23
 
21
24
  def add_word_list(name, list)
22
- @ranked_dictionaries[name] = DictionaryRanker.rank_dictionary(list)
25
+ ranked_dict = DictionaryRanker.rank_dictionary(list)
26
+ @ranked_dictionaries[name] = ranked_dict
27
+ @dictionary_tries[name] = build_trie(ranked_dict)
23
28
  end
24
29
 
25
30
  private
@@ -27,5 +32,31 @@ module Zxcvbn
27
32
  def read_word_list(file)
28
33
  DATA_PATH.join('frequency_lists', file).read.split
29
34
  end
35
+
36
+ def build_tries
37
+ @ranked_dictionaries.transform_values { |dict| build_trie(dict) }
38
+ end
39
+
40
+ def build_trie(ranked_dictionary)
41
+ trie = Trie.new
42
+ ranked_dictionary.each { |word, rank| trie.insert(word, rank) }
43
+ trie
44
+ end
45
+
46
+ def compute_graph_stats
47
+ stats = {}
48
+ @adjacency_graphs.each do |graph_name, graph|
49
+ degrees = graph.map { |_, neighbors| neighbors.compact.size }
50
+ sum = degrees.inject(0, :+)
51
+ average_degree = sum.to_f / graph.size
52
+ starting_positions = graph.length
53
+
54
+ stats[graph_name] = {
55
+ average_degree: average_degree,
56
+ starting_positions: starting_positions
57
+ }
58
+ end
59
+ stats
60
+ end
30
61
  end
31
62
  end
@@ -9,10 +9,9 @@ module Zxcvbn
9
9
  end
10
10
 
11
11
  def self.rank_dictionary(words)
12
- words.each_with_index
13
- .with_object({}) do |(word, i), dictionary|
14
- dictionary[word.downcase] = i + 1
15
- end
12
+ words
13
+ .each_with_index
14
+ .with_object({}) { |(word, i), dictionary| dictionary[word.downcase] = i + 1 }
16
15
  end
17
16
  end
18
17
  end
data/lib/zxcvbn/match.rb CHANGED
@@ -1,12 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ostruct'
4
-
5
3
  module Zxcvbn
6
- class Match < OpenStruct
4
+ class Match
5
+ attr_accessor :pattern, :i, :j, :token, :matched_word, :rank,
6
+ :dictionary_name, :reversed, :l33t, :sub, :sub_display,
7
+ :l, :entropy, :base_entropy, :uppercase_entropy, :l33t_entropy,
8
+ :repeated_char, :sequence_name, :sequence_space, :ascending,
9
+ :graph, :turns, :shifted_count, :shiffted_count,
10
+ :year, :month, :day, :separator, :cardinality, :offset
11
+
12
+ def initialize(**attributes)
13
+ attributes.each do |key, value|
14
+ instance_variable_set("@#{key}", value)
15
+ end
16
+ end
17
+
7
18
  def to_hash
8
- @table.keys.sort.each_with_object({}) do |key, hash|
9
- hash[key.to_s] = @table[key]
19
+ instance_variables.sort.each_with_object({}) do |var, hash|
20
+ key = var.to_s.delete_prefix('@')
21
+ hash[key] = instance_variable_get(var)
10
22
  end
11
23
  end
12
24
  end
@@ -8,33 +8,64 @@ module Zxcvbn
8
8
  # the lowercased password in the dictionary
9
9
 
10
10
  class Dictionary
11
- def initialize(name, ranked_dictionary)
11
+ def initialize(name, ranked_dictionary, trie = nil)
12
12
  @name = name
13
13
  @ranked_dictionary = ranked_dictionary
14
+ @trie = trie
14
15
  end
15
16
 
16
17
  def matches(password)
18
+ lowercased_password = password.downcase
19
+
20
+ if @trie
21
+ trie_matches(password, lowercased_password)
22
+ else
23
+ hash_matches(password, lowercased_password)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def trie_matches(password, lowercased_password)
30
+ results = []
31
+
32
+ (0...password.length).each do |i|
33
+ @trie.search_prefixes(lowercased_password, i).each do |word, rank, start, ending|
34
+ results << build_match(word, password.slice(start, ending - start + 1), start, ending, rank)
35
+ end
36
+ end
37
+
38
+ results
39
+ end
40
+
41
+ def hash_matches(password, lowercased_password)
17
42
  results = []
18
43
  password_length = password.length
19
- lowercased_password = password.downcase
44
+
20
45
  (0..password_length).each do |i|
21
46
  (i...password_length).each do |j|
22
- word = lowercased_password[i..j]
47
+ length = j - i + 1
48
+ word = lowercased_password.slice(i, length)
23
49
  next unless @ranked_dictionary.key?(word)
24
50
 
25
- results << Match.new(
26
- matched_word: word,
27
- token: password[i..j],
28
- i: i,
29
- j: j,
30
- rank: @ranked_dictionary[word],
31
- pattern: 'dictionary',
32
- dictionary_name: @name
33
- )
51
+ results << build_match(word, password.slice(i, length), i, j, @ranked_dictionary[word])
34
52
  end
35
53
  end
54
+
36
55
  results
37
56
  end
57
+
58
+ def build_match(matched_word, token, start_pos, end_pos, rank)
59
+ Match.new(
60
+ matched_word: matched_word,
61
+ token: token,
62
+ i: start_pos,
63
+ j: end_pos,
64
+ rank: rank,
65
+ pattern: 'dictionary',
66
+ dictionary_name: @name
67
+ )
68
+ end
38
69
  end
39
70
  end
40
71
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
4
+
3
5
  module Zxcvbn
4
6
  module Matchers
5
7
  class L33t
@@ -25,25 +27,17 @@ module Zxcvbn
25
27
  def matches(password)
26
28
  matches = []
27
29
  lowercased_password = password.downcase
28
- combinations_to_try = l33t_subs(relevent_l33t_subtable(lowercased_password))
30
+ relevent_subtable = relevent_l33t_subtable(lowercased_password)
31
+
32
+ # Early bailout: if no l33t characters present, return empty matches
33
+ return matches if relevent_subtable.empty?
34
+
35
+ combinations_to_try = l33t_subs(relevent_subtable)
29
36
  combinations_to_try.each do |substitution|
30
37
  @dictionary_matchers.each do |matcher|
31
38
  subbed_password = translate(lowercased_password, substitution)
32
39
  matcher.matches(subbed_password).each do |match|
33
- token = password[match.i..match.j]
34
- next if token.downcase == match.matched_word.downcase
35
-
36
- match_substitutions = {}
37
- substitution.each do |s, letter|
38
- match_substitutions[s] = letter if token.include?(s)
39
- end
40
- match.l33t = true
41
- match.token = password[match.i..match.j]
42
- match.sub = match_substitutions
43
- match.sub_display = match_substitutions.map do |k, v|
44
- "#{k} -> #{v}"
45
- end.join(', ')
46
- matches << match
40
+ process_match(match, password, substitution, matches)
47
41
  end
48
42
  end
49
43
  end
@@ -51,9 +45,11 @@ module Zxcvbn
51
45
  end
52
46
 
53
47
  def translate(password, sub)
54
- password.split('').map do |chr|
55
- sub[chr] || chr
56
- end.join
48
+ result = String.new
49
+ password.each_char do |chr|
50
+ result << (sub[chr] || chr)
51
+ end
52
+ result
57
53
  end
58
54
 
59
55
  def relevent_l33t_subtable(password)
@@ -80,6 +76,26 @@ module Zxcvbn
80
76
  new_subs
81
77
  end
82
78
 
79
+ private
80
+
81
+ def process_match(match, password, substitution, matches)
82
+ length = match.j - match.i + 1
83
+ token = password.slice(match.i, length)
84
+ return if token.downcase == match.matched_word.downcase
85
+
86
+ match_substitutions = {}
87
+ substitution.each do |s, letter|
88
+ match_substitutions[s] = letter if token.include?(s)
89
+ end
90
+ match.l33t = true
91
+ match.token = token
92
+ match.sub = match_substitutions
93
+ match.sub_display = match_substitutions.map do |k, v|
94
+ "#{k} -> #{v}"
95
+ end.join(', ')
96
+ matches << match
97
+ end
98
+
83
99
  def find_substitutions(subs, table, keys)
84
100
  return subs if keys.empty?
85
101
 
@@ -113,14 +129,12 @@ module Zxcvbn
113
129
 
114
130
  def dedup(subs)
115
131
  deduped = []
116
- members = []
132
+ seen = Set.new
117
133
  subs.each do |sub|
118
- assoc = sub.dup
119
-
120
- assoc.sort!
121
- label = assoc.map { |k, v| "#{k},#{v}" }.join('-')
122
- unless members.include?(label)
123
- members << label
134
+ # Sort and convert to hash for consistent comparison
135
+ sorted_sub = sub.sort.to_h
136
+ unless seen.include?(sorted_sub)
137
+ seen.add(sorted_sub)
124
138
  deduped << sub
125
139
  end
126
140
  end
@@ -30,7 +30,8 @@ module Zxcvbn
30
30
  @dictionary_matchers.each do |matcher|
31
31
  subbed_password = substitute(lowercased_password, substitutions)
32
32
  matcher.matches(subbed_password).each do |match|
33
- token = lowercased_password[match.i..match.j]
33
+ length = match.j - match.i + 1
34
+ token = lowercased_password.slice(match.i, length)
34
35
  next if token == match.matched_word.downcase
35
36
 
36
37
  match_substitutions = {}
@@ -38,7 +39,7 @@ module Zxcvbn
38
39
  match_substitutions[substitution] = letter if token.include?(substitution)
39
40
  end
40
41
  match.l33t = true
41
- match.token = password[match.i..match.j]
42
+ match.token = password.slice(match.i, length)
42
43
  match.sub = match_substitutions
43
44
  match.sub_display = match_substitutions.map do |k, v|
44
45
  "#{k} -> #{v}"
@@ -15,7 +15,7 @@ module Zxcvbn
15
15
  match = Match.new(
16
16
  i: i,
17
17
  j: j,
18
- token: password[i..j]
18
+ token: password.slice(i, j - i + 1)
19
19
  )
20
20
  yield match, re_match
21
21
  end
@@ -18,7 +18,7 @@ module Zxcvbn
18
18
  pattern: 'repeat',
19
19
  i: i,
20
20
  j: j - 1,
21
- token: password[i...j],
21
+ token: password.slice(i, j - i),
22
22
  repeated_char: cur_char
23
23
  )
24
24
  end
@@ -64,7 +64,7 @@ module Zxcvbn
64
64
  pattern: 'spatial',
65
65
  i: i,
66
66
  j: j - 1,
67
- token: password[i...j],
67
+ token: password.slice(i, j - i),
68
68
  graph: graph_name,
69
69
  turns: turns,
70
70
  shifted_count: shifted_count
data/lib/zxcvbn/math.rb CHANGED
@@ -34,6 +34,10 @@ module Zxcvbn
34
34
  return 0 if k > n
35
35
  return 1 if k.zero?
36
36
 
37
+ # Use symmetry property: C(n,k) = C(n, n-k)
38
+ # Choose smaller k to minimize iterations
39
+ k = n - k if k > n - k
40
+
37
41
  r = 1
38
42
  (1..k).each do |d|
39
43
  r *= n
@@ -44,14 +48,11 @@ module Zxcvbn
44
48
  end
45
49
 
46
50
  def average_degree_for_graph(graph_name)
47
- graph = data.adjacency_graphs[graph_name]
48
- degrees = graph.map { |_, neighbors| neighbors.compact.size }
49
- sum = degrees.inject(0, :+)
50
- sum.to_f / graph.size
51
+ data.graph_stats[graph_name][:average_degree]
51
52
  end
52
53
 
53
54
  def starting_positions_for_graph(graph_name)
54
- data.adjacency_graphs[graph_name].length
55
+ data.graph_stats[graph_name][:starting_positions]
55
56
  end
56
57
  end
57
58
  end
@@ -38,7 +38,8 @@ module Zxcvbn
38
38
  def build_matchers
39
39
  matchers = []
40
40
  dictionary_matchers = @data.ranked_dictionaries.map do |name, dictionary|
41
- Matchers::Dictionary.new(name, dictionary)
41
+ trie = @data.dictionary_tries[name]
42
+ Matchers::Dictionary.new(name, dictionary, trie)
42
43
  end
43
44
  l33t_matcher = Matchers::L33t.new(dictionary_matchers)
44
45
  matchers += dictionary_matchers
data/lib/zxcvbn/scorer.rb CHANGED
@@ -96,7 +96,7 @@ module Zxcvbn
96
96
  pattern: 'bruteforce',
97
97
  i: i,
98
98
  j: j,
99
- token: password[i..j],
99
+ token: password.slice(i, j - i + 1),
100
100
  entropy: lg(bruteforce_cardinality**(j - i + 1)),
101
101
  cardinality: bruteforce_cardinality
102
102
  )
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zxcvbn
4
+ # A trie (prefix tree) data structure for efficient dictionary matching.
5
+ # Provides fast prefix-based lookups to eliminate unnecessary substring checks.
6
+ #
7
+ # @see https://en.wikipedia.org/wiki/Trie
8
+ class Trie
9
+ def initialize
10
+ @root = {}
11
+ end
12
+
13
+ # Insert a word and its rank into the trie
14
+ # @param word [String] the word to insert
15
+ # @param rank [Integer] the rank/frequency of the word
16
+ def insert(word, rank)
17
+ node = @root
18
+ word.each_char do |char|
19
+ node[char] ||= {}
20
+ node = node[char]
21
+ end
22
+ node[:rank] = rank
23
+ end
24
+
25
+ # Search for all words in the text starting from a given position
26
+ # @param text [String] the text to search in
27
+ # @param start_pos [Integer] the starting position
28
+ # @return [Array<Array>] array of [word, rank, start, end] tuples
29
+ def search_prefixes(text, start_pos)
30
+ results = []
31
+ node = @root
32
+
33
+ (start_pos...text.length).each do |i|
34
+ char = text[i]
35
+ break unless node[char]
36
+
37
+ node = node[char]
38
+ results << [text[start_pos..i], node[:rank], start_pos, i] if node[:rank]
39
+ end
40
+
41
+ results
42
+ end
43
+ end
44
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zxcvbn
4
- VERSION = '1.2.4'
4
+ VERSION = '1.3.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zxcvbn-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.4
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steve Hodgkiss
@@ -52,6 +52,7 @@ files:
52
52
  - lib/zxcvbn/score.rb
53
53
  - lib/zxcvbn/scorer.rb
54
54
  - lib/zxcvbn/tester.rb
55
+ - lib/zxcvbn/trie.rb
55
56
  - lib/zxcvbn/version.rb
56
57
  homepage: http://github.com/envato/zxcvbn-ruby
57
58
  licenses:
@@ -76,7 +77,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
76
77
  - !ruby/object:Gem::Version
77
78
  version: '0'
78
79
  requirements: []
79
- rubygems_version: 4.0.0
80
+ rubygems_version: 4.0.3
80
81
  specification_version: 4
81
82
  summary: ''
82
83
  test_files: []