zxcvbn-ruby 0.0.1
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.
- 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
|