zxcvbn-ruby 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +18 -0
- data/.rspec +1 -0
- data/Gemfile +3 -0
- data/LICENSE +22 -0
- data/README.md +23 -0
- data/Rakefile +18 -0
- data/data/adjacency_graphs.json +9 -0
- data/data/frequency_lists.yaml +85094 -0
- data/lib/zxcvbn.rb +37 -0
- data/lib/zxcvbn/crack_time.rb +51 -0
- data/lib/zxcvbn/dictionary_ranker.rb +23 -0
- data/lib/zxcvbn/entropy.rb +151 -0
- data/lib/zxcvbn/match.rb +13 -0
- data/lib/zxcvbn/matchers/date.rb +134 -0
- data/lib/zxcvbn/matchers/dictionary.rb +34 -0
- data/lib/zxcvbn/matchers/digits.rb +18 -0
- data/lib/zxcvbn/matchers/l33t.rb +127 -0
- data/lib/zxcvbn/matchers/new_l33t.rb +120 -0
- data/lib/zxcvbn/matchers/regex_helpers.rb +21 -0
- data/lib/zxcvbn/matchers/repeat.rb +32 -0
- data/lib/zxcvbn/matchers/sequences.rb +64 -0
- data/lib/zxcvbn/matchers/spatial.rb +79 -0
- data/lib/zxcvbn/matchers/year.rb +18 -0
- data/lib/zxcvbn/math.rb +63 -0
- data/lib/zxcvbn/omnimatch.rb +49 -0
- data/lib/zxcvbn/password_strength.rb +21 -0
- data/lib/zxcvbn/score.rb +15 -0
- data/lib/zxcvbn/scorer.rb +84 -0
- data/lib/zxcvbn/version.rb +3 -0
- data/spec/matchers/date_spec.rb +109 -0
- data/spec/matchers/dictionary_spec.rb +14 -0
- data/spec/matchers/digits_spec.rb +15 -0
- data/spec/matchers/l33t_spec.rb +85 -0
- data/spec/matchers/repeat_spec.rb +18 -0
- data/spec/matchers/sequences_spec.rb +16 -0
- data/spec/matchers/spatial_spec.rb +20 -0
- data/spec/matchers/year_spec.rb +15 -0
- data/spec/omnimatch_spec.rb +24 -0
- data/spec/scorer_spec.rb +5 -0
- data/spec/scoring/crack_time_spec.rb +106 -0
- data/spec/scoring/entropy_spec.rb +213 -0
- data/spec/scoring/math_spec.rb +131 -0
- data/spec/spec_helper.rb +54 -0
- data/spec/support/js_helpers.rb +35 -0
- data/spec/support/js_source/adjacency_graphs.js +8 -0
- data/spec/support/js_source/compiled.js +1188 -0
- data/spec/support/js_source/frequency_lists.js +10 -0
- data/spec/support/js_source/init.coffee +63 -0
- data/spec/support/js_source/init.js +95 -0
- data/spec/support/js_source/matching.coffee +444 -0
- data/spec/support/js_source/matching.js +685 -0
- data/spec/support/js_source/scoring.coffee +270 -0
- data/spec/support/js_source/scoring.js +390 -0
- data/spec/support/matcher.rb +35 -0
- data/spec/zxcvbn_spec.rb +49 -0
- data/zxcvbn-ruby.gemspec +20 -0
- metadata +167 -0
@@ -0,0 +1,18 @@
|
|
1
|
+
module Zxcvbn
|
2
|
+
module Matchers
|
3
|
+
class Year
|
4
|
+
include RegexHelpers
|
5
|
+
|
6
|
+
YEAR_REGEX = /19\d\d|200\d|201\d/
|
7
|
+
|
8
|
+
def matches(password)
|
9
|
+
result = []
|
10
|
+
re_match_all(YEAR_REGEX, password) do |match|
|
11
|
+
match.pattern = 'year'
|
12
|
+
result << match
|
13
|
+
end
|
14
|
+
result
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
data/lib/zxcvbn/math.rb
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
module Zxcvbn
|
2
|
+
module Math
|
3
|
+
def bruteforce_cardinality(password)
|
4
|
+
is_type_of = {}
|
5
|
+
|
6
|
+
password.each_byte do |ordinal|
|
7
|
+
case ordinal
|
8
|
+
when (48..57)
|
9
|
+
is_type_of['digits'] = true
|
10
|
+
when (65..90)
|
11
|
+
is_type_of['upper'] = true
|
12
|
+
when (97..122)
|
13
|
+
is_type_of['lower'] = true
|
14
|
+
else
|
15
|
+
is_type_of['symbols'] = true
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
cardinality = 0
|
20
|
+
cardinality += 10 if is_type_of['digits']
|
21
|
+
cardinality += 26 if is_type_of['upper']
|
22
|
+
cardinality += 26 if is_type_of['lower']
|
23
|
+
cardinality += 33 if is_type_of['symbols']
|
24
|
+
cardinality
|
25
|
+
end
|
26
|
+
|
27
|
+
def lg(n)
|
28
|
+
::Math.log(n, 2)
|
29
|
+
end
|
30
|
+
|
31
|
+
def nCk(n, k)
|
32
|
+
return 0 if k > n
|
33
|
+
return 1 if k == 0
|
34
|
+
r = 1
|
35
|
+
(1..k).each do |d|
|
36
|
+
r = r * n
|
37
|
+
r = r / d
|
38
|
+
n -= 1
|
39
|
+
end
|
40
|
+
r
|
41
|
+
end
|
42
|
+
|
43
|
+
def min(a, b)
|
44
|
+
a < b ? a : b
|
45
|
+
end
|
46
|
+
|
47
|
+
def average_degree_for_graph(graph_name)
|
48
|
+
graph = Zxcvbn::ADJACENCY_GRAPHS[graph_name]
|
49
|
+
average = 0.0
|
50
|
+
|
51
|
+
graph.each do |key, neighbors|
|
52
|
+
average += neighbors.compact.length
|
53
|
+
end
|
54
|
+
|
55
|
+
average /= graph.keys.length
|
56
|
+
average
|
57
|
+
end
|
58
|
+
|
59
|
+
def starting_positions_for_graph(graph_name)
|
60
|
+
Zxcvbn::ADJACENCY_GRAPHS[graph_name].length
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'yaml'
|
3
|
+
require 'pathname'
|
4
|
+
|
5
|
+
module Zxcvbn
|
6
|
+
class Omnimatch
|
7
|
+
def initialize
|
8
|
+
@matchers = build_matchers
|
9
|
+
end
|
10
|
+
|
11
|
+
def matches(password, user_inputs = [])
|
12
|
+
result = []
|
13
|
+
(@matchers + user_input_matchers(user_inputs)).each do |matcher|
|
14
|
+
result += matcher.matches(password)
|
15
|
+
end
|
16
|
+
result
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def user_input_matchers(user_inputs)
|
22
|
+
return [] unless user_inputs.any?
|
23
|
+
user_ranked_dictionary = DictionaryRanker.rank_dictionary(user_inputs)
|
24
|
+
dictionary_matcher = Matchers::Dictionary.new('user_inputs', user_ranked_dictionary)
|
25
|
+
l33t_matcher = Matchers::L33t.new([dictionary_matcher])
|
26
|
+
[dictionary_matcher, l33t_matcher]
|
27
|
+
end
|
28
|
+
|
29
|
+
|
30
|
+
def build_matchers
|
31
|
+
matchers = []
|
32
|
+
dictionary_matchers = RANKED_DICTIONARIES.map do |name, dictionary|
|
33
|
+
Matchers::Dictionary.new(name, dictionary)
|
34
|
+
end
|
35
|
+
l33t_matcher = Matchers::L33t.new(dictionary_matchers)
|
36
|
+
matchers += dictionary_matchers
|
37
|
+
matchers += [
|
38
|
+
l33t_matcher,
|
39
|
+
Matchers::Spatial.new(ADJACENCY_GRAPHS),
|
40
|
+
Matchers::Digits.new,
|
41
|
+
Matchers::Repeat.new,
|
42
|
+
Matchers::Sequences.new,
|
43
|
+
Matchers::Year.new,
|
44
|
+
Matchers::Date.new
|
45
|
+
]
|
46
|
+
matchers
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'benchmark'
|
2
|
+
|
3
|
+
module Zxcvbn
|
4
|
+
class PasswordStrength
|
5
|
+
def initialize
|
6
|
+
@omnimatch = Omnimatch.new
|
7
|
+
@scorer = Scorer.new
|
8
|
+
end
|
9
|
+
|
10
|
+
def test(password, user_inputs = [])
|
11
|
+
password = password || ''
|
12
|
+
result = nil
|
13
|
+
calc_time = Benchmark.realtime do
|
14
|
+
matches = @omnimatch.matches(password, user_inputs)
|
15
|
+
result = @scorer.minimum_entropy_match_sequence(password, matches)
|
16
|
+
end
|
17
|
+
result.calc_time = calc_time
|
18
|
+
result
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
data/lib/zxcvbn/score.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
module Zxcvbn
|
2
|
+
class Score
|
3
|
+
attr_accessor :entropy, :crack_time, :crack_time_display, :score, :pattern,
|
4
|
+
:match_sequence, :password, :calc_time
|
5
|
+
|
6
|
+
def initialize(options = {})
|
7
|
+
@entropy = options[:entropy]
|
8
|
+
@crack_time = options[:crack_time]
|
9
|
+
@crack_time_display = options[:crack_time_display]
|
10
|
+
@score = options[:score]
|
11
|
+
@match_sequence = options[:match_sequence]
|
12
|
+
@password = options[:password]
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
module Zxcvbn
|
2
|
+
class Scorer
|
3
|
+
include Entropy
|
4
|
+
include CrackTime
|
5
|
+
|
6
|
+
def minimum_entropy_match_sequence(password, matches)
|
7
|
+
bruteforce_cardinality = bruteforce_cardinality(password) # e.g. 26 for lowercase
|
8
|
+
up_to_k = [] # minimum entropy up to k.
|
9
|
+
backpointers = [] # for the optimal sequence of matches up to k, holds the final match (match.j == k). null means the sequence ends w/ a brute-force character.
|
10
|
+
(0...password.length).each do |k|
|
11
|
+
# starting scenario to try and beat: adding a brute-force character to the minimum entropy sequence at k-1.
|
12
|
+
previous_k_entropy = k == 0 ? 0 : up_to_k[k-1]
|
13
|
+
up_to_k[k] = previous_k_entropy + lg(bruteforce_cardinality)
|
14
|
+
backpointers[k] = nil
|
15
|
+
matches.each do |match|
|
16
|
+
next unless match.j == k
|
17
|
+
i, j = match.i, match.j
|
18
|
+
# see if best entropy up to i-1 + entropy of this match is less than the current minimum at j.
|
19
|
+
previous_i_entropy = i > 0 ? up_to_k[i-1] : 0
|
20
|
+
candidate_entropy = previous_i_entropy + calc_entropy(match)
|
21
|
+
if up_to_k[j] && candidate_entropy < up_to_k[j]
|
22
|
+
up_to_k[j] = candidate_entropy
|
23
|
+
backpointers[j] = match
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
# walk backwards and decode the best sequence
|
28
|
+
match_sequence = []
|
29
|
+
k = password.length - 1
|
30
|
+
while k >= 0
|
31
|
+
match = backpointers[k]
|
32
|
+
if match
|
33
|
+
match_sequence.push match
|
34
|
+
k = match.i - 1
|
35
|
+
else
|
36
|
+
k -= 1
|
37
|
+
end
|
38
|
+
end
|
39
|
+
match_sequence.reverse!
|
40
|
+
|
41
|
+
# fill in the blanks between pattern matches with bruteforce "matches"
|
42
|
+
# that way the match sequence fully covers the password: match1.j == match2.i - 1 for every adjacent match1, match2.
|
43
|
+
make_bruteforce_match = lambda do |i, j|
|
44
|
+
Match.new(
|
45
|
+
:pattern => 'bruteforce',
|
46
|
+
:i => i,
|
47
|
+
:j => j,
|
48
|
+
:token => password[i..j],
|
49
|
+
:entropy => lg(bruteforce_cardinality ** (j - i + 1)),
|
50
|
+
:cardinality => bruteforce_cardinality
|
51
|
+
)
|
52
|
+
end
|
53
|
+
|
54
|
+
k = 0
|
55
|
+
match_sequence_copy = []
|
56
|
+
match_sequence.each do |match|
|
57
|
+
i, j = match.i, match.j
|
58
|
+
if i - k > 0
|
59
|
+
debugger if i == 0
|
60
|
+
match_sequence_copy << make_bruteforce_match.call(k, i - 1)
|
61
|
+
end
|
62
|
+
k = j + 1
|
63
|
+
match_sequence_copy.push match
|
64
|
+
end
|
65
|
+
if k < password.length
|
66
|
+
match_sequence_copy.push make_bruteforce_match.call(k, password.length - 1)
|
67
|
+
end
|
68
|
+
match_sequence = match_sequence_copy
|
69
|
+
|
70
|
+
min_entropy = up_to_k[password.length - 1] || 0 # or 0 corner case is for an empty password ''
|
71
|
+
crack_time = entropy_to_crack_time(min_entropy)
|
72
|
+
|
73
|
+
# final result object
|
74
|
+
Score.new(
|
75
|
+
:password => password,
|
76
|
+
:entropy => min_entropy.round(3),
|
77
|
+
:match_sequence => match_sequence,
|
78
|
+
:crack_time => crack_time.round(3),
|
79
|
+
:crack_time_display => display_time(crack_time),
|
80
|
+
:score => crack_time_to_score(crack_time)
|
81
|
+
)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Zxcvbn::Matchers::Date do
|
4
|
+
let(:matcher) { subject }
|
5
|
+
|
6
|
+
{
|
7
|
+
' ' => 'testing02 12 1997',
|
8
|
+
'-' => 'testing02-12-1997',
|
9
|
+
'/' => 'testing02/12/1997',
|
10
|
+
'\\' => 'testing02\12\1997',
|
11
|
+
'_' => 'testing02_12_1997',
|
12
|
+
'.' => 'testing02.12.1997'
|
13
|
+
}.each do |separator, password|
|
14
|
+
context "with #{separator} seperator" do
|
15
|
+
let(:matches) { matcher.matches(password) }
|
16
|
+
|
17
|
+
it 'finds matches' do
|
18
|
+
matches.should_not be_empty
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'finds the correct matches' do
|
22
|
+
matches.count.should eq 1
|
23
|
+
matches[0].token.should eq %w[ 02 12 1997 ].join(separator)
|
24
|
+
matches[0].separator.should eq separator
|
25
|
+
matches[0].day.should eq 2
|
26
|
+
matches[0].month.should eq 12
|
27
|
+
matches[0].year.should eq 1997
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# context 'without separator' do
|
33
|
+
# context '5 digit date' do
|
34
|
+
# let(:matches) { matcher.matches('13192boo') }
|
35
|
+
|
36
|
+
# it 'finds matches' do
|
37
|
+
# matches.should_not be_empty
|
38
|
+
# end
|
39
|
+
|
40
|
+
# it 'finds the correct matches' do
|
41
|
+
# matches.count.should eq 2
|
42
|
+
# matches[0].token.should eq '13192'
|
43
|
+
# matches[0].separator.should eq ''
|
44
|
+
# matches[0].day.should eq 13
|
45
|
+
# matches[0].month.should eq 1
|
46
|
+
# matches[0].year.should eq 1992
|
47
|
+
|
48
|
+
# matches[1].token.should eq '13192'
|
49
|
+
# matches[1].separator.should eq ''
|
50
|
+
# matches[1].day.should eq 31
|
51
|
+
# matches[1].month.should eq 1
|
52
|
+
# matches[1].year.should eq 1992
|
53
|
+
# end
|
54
|
+
# end
|
55
|
+
# end
|
56
|
+
|
57
|
+
# describe '#extract_dates' do
|
58
|
+
# {
|
59
|
+
# '1234' => [
|
60
|
+
# {:year => 2012, :month => 3, :day => 4},
|
61
|
+
# {:year => 1934, :month => 2, :day => 1},
|
62
|
+
# {:year => 1934, :month => 1, :day => 2}
|
63
|
+
# ],
|
64
|
+
# '12345' => [
|
65
|
+
# {:year => 1945, :month => 3, :day => 12},
|
66
|
+
# {:year => 1945, :month => 12, :day => 3},
|
67
|
+
# {:year => 1945, :month => 1, :day => 23}
|
68
|
+
# ],
|
69
|
+
# '54321' => [
|
70
|
+
# {:year => 1954, :month => 3, :day => 21}
|
71
|
+
# ],
|
72
|
+
# '151290' => [
|
73
|
+
# {:year => 1990, :month => 12, :day => 15}
|
74
|
+
# ],
|
75
|
+
# '901215' => [
|
76
|
+
# {:year => 1990, :month => 12, :day => 15}
|
77
|
+
# ],
|
78
|
+
# '1511990' => [
|
79
|
+
# {:year => 1990, :month => 1, :day => 15}
|
80
|
+
# ]
|
81
|
+
# }.each do |token, expected_candidates|
|
82
|
+
# it "finds the correct candidates for #{token}" do
|
83
|
+
# matcher.extract_dates(token).should match_array expected_candidates
|
84
|
+
# end
|
85
|
+
# end
|
86
|
+
# end
|
87
|
+
|
88
|
+
# describe '#expand_year' do
|
89
|
+
# {
|
90
|
+
# 12 => 2012,
|
91
|
+
# 01 => 2001,
|
92
|
+
# 15 => 2015,
|
93
|
+
# 19 => 2019,
|
94
|
+
# 20 => 1920
|
95
|
+
# }.each do |small, expanded|
|
96
|
+
# it "expands #{small} to #{expanded}" do
|
97
|
+
# matcher.expand_year(small).should eq expanded
|
98
|
+
# end
|
99
|
+
# end
|
100
|
+
# end
|
101
|
+
|
102
|
+
context 'invalid date' do
|
103
|
+
let(:matches) { matcher.matches('testing0.x.1997') }
|
104
|
+
|
105
|
+
it 'doesnt match' do
|
106
|
+
matches.should be_empty
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Zxcvbn::Matchers::Dictionary do
|
4
|
+
let(:matcher) { described_class.new('english', dictionary) }
|
5
|
+
let(:dictionary) { Zxcvbn::RANKED_DICTIONARIES['english'] }
|
6
|
+
|
7
|
+
it 'finds all the matches' do
|
8
|
+
matches = matcher.matches('whatisinit')
|
9
|
+
matches.count.should == 14
|
10
|
+
expected_matches = ['wha', 'what', 'ha', 'hat', 'a', 'at', 'tis', 'i', 'is',
|
11
|
+
'sin', 'i', 'in', 'i', 'it']
|
12
|
+
matches.map(&:matched_word).should == expected_matches
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Zxcvbn::Matchers::Digits do
|
4
|
+
let(:matcher) { subject }
|
5
|
+
let(:matches) { matcher.matches('testing1239xx9712') }
|
6
|
+
|
7
|
+
it 'sets the pattern name' do
|
8
|
+
matches.all? { |m| m.pattern == 'digits' }.should be_true
|
9
|
+
end
|
10
|
+
|
11
|
+
it 'finds the correct matches' do
|
12
|
+
matches.count.should == 2
|
13
|
+
matches[0].token.should eq '1239'
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Zxcvbn::Matchers::L33t do
|
4
|
+
let(:matcher) { described_class.new([dictionary_matcher]) }
|
5
|
+
let(:dictionary) { Zxcvbn::RANKED_DICTIONARIES['english'] }
|
6
|
+
let(:dictionary_matcher) { Zxcvbn::Matchers::Dictionary.new('english', dictionary) }
|
7
|
+
|
8
|
+
describe '#relevant_l33t_substitutions' do
|
9
|
+
it 'returns relevant l33t substitutions' do
|
10
|
+
matcher.relevent_l33t_subtable('p@ssw1rd24').should eq(
|
11
|
+
{'a' => ['4', '@'], 'i' => ['1'], 'l' => ['1'], 'z' => ['2']}
|
12
|
+
)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
describe 'possible l33t substitutions' do
|
17
|
+
context 'with 2 possible substitutions' do
|
18
|
+
it 'returns the correct possible substitutions' do
|
19
|
+
substitutions = {'a' => ['@'], 'i' => ['1']}
|
20
|
+
matcher.l33t_subs(substitutions).should match_array([
|
21
|
+
{'@' => 'a', '1' => 'i'}
|
22
|
+
])
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'returns the correct possible substitutions with multiple options' do
|
26
|
+
substitutions = {'a' => ['@', '4'], 'i' => ['1']}
|
27
|
+
matcher.l33t_subs(substitutions).should match_array([
|
28
|
+
{'@' => 'a', '1' => 'i'},
|
29
|
+
{'4' => 'a', '1' => 'i'}
|
30
|
+
])
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
context 'with 3 possible substitutions' do
|
35
|
+
it 'returns the correct possible substitutions' do
|
36
|
+
substitutions = {'a' => ['@'], 'i' => ['1'], 'z' => ['3']}
|
37
|
+
matcher.l33t_subs(substitutions).should match_array([
|
38
|
+
{'@' => 'a', '1' => 'i', '3' => 'z'}
|
39
|
+
])
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
context 'with 4 possible substitutions' do
|
44
|
+
it 'returns the correct possible substitutions' do
|
45
|
+
substitutions = {'a' => ['@'], 'i' => ['1'], 'z' => ['3'], 'b' => ['8']}
|
46
|
+
matcher.l33t_subs(substitutions).should match_array([
|
47
|
+
{'@' => 'a', '1' => 'i', '3' => 'z', '8' => 'b'}
|
48
|
+
])
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
describe '#matches' do
|
54
|
+
let(:matches) { matcher.matches('p@ssword') }
|
55
|
+
# it doesn't match on 'password' because that's not in the english
|
56
|
+
# dictionary/frequency list
|
57
|
+
|
58
|
+
it 'finds the correct matches' do
|
59
|
+
matches.map(&:matched_word).should eq([
|
60
|
+
'pas',
|
61
|
+
'a',
|
62
|
+
'as',
|
63
|
+
'ass'
|
64
|
+
])
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'sets the token correctly on those matches' do
|
68
|
+
matches.map(&:token).should eq([
|
69
|
+
'p@s',
|
70
|
+
'@',
|
71
|
+
'@s',
|
72
|
+
'@ss'
|
73
|
+
])
|
74
|
+
end
|
75
|
+
|
76
|
+
it 'sets the substituions used' do
|
77
|
+
matches.map(&:sub).should eq([
|
78
|
+
{'@' => 'a'},
|
79
|
+
{'@' => 'a'},
|
80
|
+
{'@' => 'a'},
|
81
|
+
{'@' => 'a'}
|
82
|
+
])
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|