Skip to content

Commit 52e6b47

Browse files
authored
Merge pull request #64 from envato/optimise-l33t-matcher
Optimise L33t matcher performance and improve test coverage
2 parents 61b7013 + 69baa3f commit 52e6b47

File tree

4 files changed

+144
-26
lines changed

4 files changed

+144
-26
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
- Replace OpenStruct with regular class in `Zxcvbn::Match` for 2x performance improvement ([#61])
1111
- Implement Trie data structure for dictionary matching with 1.4x additional performance improvement ([#62])
1212
- Replace range operators with `String#slice` for string slicing operations ([#63])
13+
- Optimise L33t matcher with early bailout and improved deduplication ([#64])
1314

1415
[Unreleased]: https://github.com/envato/zxcvbn-ruby/compare/v1.2.4...HEAD
1516
[#61]: https://github.com/envato/zxcvbn-ruby/pull/61
1617
[#62]: https://github.com/envato/zxcvbn-ruby/pull/62
1718
[#63]: https://github.com/envato/zxcvbn-ruby/pull/63
19+
[#64]: https://github.com/envato/zxcvbn-ruby/pull/64
1820

1921
## [1.2.4] - 2025-12-07
2022

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ source 'https://rubygems.org'
55
gemspec
66

77
group :development do
8+
gem 'benchmark'
89
gem 'guard'
910
gem 'guard-bundler', require: false
1011
gem 'guard-rspec', require: false

lib/zxcvbn/matchers/l33t.rb

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require 'set'
4+
35
module Zxcvbn
46
module Matchers
57
class L33t
@@ -25,36 +27,29 @@ def initialize(dictionary_matchers)
2527
def matches(password)
2628
matches = []
2729
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)
2936
combinations_to_try.each do |substitution|
3037
@dictionary_matchers.each do |matcher|
3138
subbed_password = translate(lowercased_password, substitution)
3239
matcher.matches(subbed_password).each do |match|
33-
length = match.j - match.i + 1
34-
token = password.slice(match.i, length)
35-
next if token.downcase == match.matched_word.downcase
36-
37-
match_substitutions = {}
38-
substitution.each do |s, letter|
39-
match_substitutions[s] = letter if token.include?(s)
40-
end
41-
match.l33t = true
42-
match.token = token
43-
match.sub = match_substitutions
44-
match.sub_display = match_substitutions.map do |k, v|
45-
"#{k} -> #{v}"
46-
end.join(', ')
47-
matches << match
40+
process_match(match, password, substitution, matches)
4841
end
4942
end
5043
end
5144
matches
5245
end
5346

5447
def translate(password, sub)
55-
password.split('').map do |chr|
56-
sub[chr] || chr
57-
end.join
48+
result = String.new
49+
password.each_char do |chr|
50+
result << (sub[chr] || chr)
51+
end
52+
result
5853
end
5954

6055
def relevent_l33t_subtable(password)
@@ -81,6 +76,26 @@ def l33t_subs(table)
8176
new_subs
8277
end
8378

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+
8499
def find_substitutions(subs, table, keys)
85100
return subs if keys.empty?
86101

@@ -114,14 +129,12 @@ def find_substitutions(subs, table, keys)
114129

115130
def dedup(subs)
116131
deduped = []
117-
members = []
132+
seen = Set.new
118133
subs.each do |sub|
119-
assoc = sub.dup
120-
121-
assoc.sort!
122-
label = assoc.map { |k, v| "#{k},#{v}" }.join('-')
123-
unless members.include?(label)
124-
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)
125138
deduped << sub
126139
end
127140
end

spec/matchers/l33t_spec.rb

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,5 +73,107 @@
7373
]
7474
)
7575
end
76+
77+
it 'marks all matches as l33t' do
78+
expect(matches.map(&:l33t).uniq).to eq([true])
79+
end
80+
81+
it 'sets the sub_display field' do
82+
expect(matches.first.sub_display).to eq('@ -> a')
83+
end
84+
85+
context 'with no l33t characters' do
86+
it 'returns empty array for password without l33t chars' do
87+
expect(matcher.matches('password')).to be_empty
88+
end
89+
90+
it 'returns empty array for simple words' do
91+
expect(matcher.matches('hello')).to be_empty
92+
end
93+
end
94+
95+
context 'with multiple l33t substitutions' do
96+
it 'handles multiple substitution types' do
97+
matches = matcher.matches('p@ssw0rd')
98+
expect(matches).not_to be_empty
99+
expect(matches.any? { |m| m.sub.keys.include?('@') }).to be true
100+
expect(matches.any? { |m| m.sub.keys.include?('0') }).to be true
101+
end
102+
103+
it 'creates correct sub_display for multiple substitutions' do
104+
matches = matcher.matches('h3ll0')
105+
multi_sub_match = matches.find { |m| m.sub.length > 1 }
106+
expect(multi_sub_match.sub_display).to include('->')
107+
end
108+
end
109+
110+
context 'with same character representing different letters' do
111+
it 'handles ambiguous l33t characters' do
112+
# '1' can represent both 'i' and 'l'
113+
matches = matcher.matches('test1ng')
114+
expect(matches).not_to be_empty
115+
end
116+
end
117+
118+
context 'with uppercase l33t speak' do
119+
it 'finds matches in mixed case passwords' do
120+
matches = matcher.matches('P@ssW0RD')
121+
expect(matches).not_to be_empty
122+
end
123+
124+
it 'preserves original case in token' do
125+
matches = matcher.matches('P@SS')
126+
uppercase_match = matches.find { |m| m.token == 'P@S' }
127+
expect(uppercase_match).not_to be_nil
128+
expect(uppercase_match.token).to eq('P@S')
129+
expect(uppercase_match.matched_word).to eq('pas')
130+
end
131+
end
132+
133+
context 'with edge cases' do
134+
it 'handles empty password' do
135+
expect(matcher.matches('')).to be_empty
136+
end
137+
138+
it 'handles password with only l33t characters' do
139+
matches = matcher.matches('@$')
140+
expect(matches).to be_an(Array)
141+
end
142+
143+
it 'handles repeated l33t characters' do
144+
matches = matcher.matches('@@@@')
145+
expect(matches).to be_an(Array)
146+
end
147+
end
148+
end
149+
150+
describe '#translate' do
151+
it 'substitutes l33t characters with their letter equivalents' do
152+
substitution = { '@' => 'a', '0' => 'o' }
153+
expect(matcher.translate('p@ssw0rd', substitution)).to eq('password')
154+
end
155+
156+
it 'leaves non-substituted characters unchanged' do
157+
substitution = { '@' => 'a' }
158+
expect(matcher.translate('p@ssword', substitution)).to eq('password')
159+
end
160+
161+
it 'handles empty password' do
162+
expect(matcher.translate('', { '@' => 'a' })).to eq('')
163+
end
164+
165+
it 'handles empty substitution table' do
166+
expect(matcher.translate('password', {})).to eq('password')
167+
end
168+
169+
it 'handles multiple occurrences of same character' do
170+
substitution = { '@' => 'a' }
171+
expect(matcher.translate('@@@@', substitution)).to eq('aaaa')
172+
end
173+
174+
it 'only substitutes specified characters' do
175+
substitution = { '3' => 'e' }
176+
expect(matcher.translate('l33t', substitution)).to eq('leet')
177+
end
76178
end
77179
end

0 commit comments

Comments
 (0)