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 +4 -4
- data/CHANGELOG.md +21 -1
- data/lib/zxcvbn/data.rb +33 -2
- data/lib/zxcvbn/dictionary_ranker.rb +3 -4
- data/lib/zxcvbn/match.rb +17 -5
- data/lib/zxcvbn/matchers/dictionary.rb +43 -12
- data/lib/zxcvbn/matchers/l33t.rb +39 -25
- data/lib/zxcvbn/matchers/new_l33t.rb +3 -2
- data/lib/zxcvbn/matchers/regex_helpers.rb +1 -1
- data/lib/zxcvbn/matchers/repeat.rb +1 -1
- data/lib/zxcvbn/matchers/spatial.rb +1 -1
- data/lib/zxcvbn/math.rb +6 -5
- data/lib/zxcvbn/omnimatch.rb +2 -1
- data/lib/zxcvbn/scorer.rb +1 -1
- data/lib/zxcvbn/trie.rb +44 -0
- data/lib/zxcvbn/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36aa566fe4268e91239c2232628e3eb7397cdf06c99608efa63670842a5b5b4c
|
|
4
|
+
data.tar.gz: 8c736d0a2f84507600e9ba3da51a368f056c2e8918672238415f636bffcd6ca3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
44
|
+
|
|
20
45
|
(0..password_length).each do |i|
|
|
21
46
|
(i...password_length).each do |j|
|
|
22
|
-
|
|
47
|
+
length = j - i + 1
|
|
48
|
+
word = lowercased_password.slice(i, length)
|
|
23
49
|
next unless @ranked_dictionary.key?(word)
|
|
24
50
|
|
|
25
|
-
results <<
|
|
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
|
data/lib/zxcvbn/matchers/l33t.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
132
|
+
seen = Set.new
|
|
117
133
|
subs.each do |sub|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
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}"
|
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
|
-
|
|
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.
|
|
55
|
+
data.graph_stats[graph_name][:starting_positions]
|
|
55
56
|
end
|
|
56
57
|
end
|
|
57
58
|
end
|
data/lib/zxcvbn/omnimatch.rb
CHANGED
|
@@ -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
|
-
|
|
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
data/lib/zxcvbn/trie.rb
ADDED
|
@@ -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
|
data/lib/zxcvbn/version.rb
CHANGED
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.
|
|
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.
|
|
80
|
+
rubygems_version: 4.0.3
|
|
80
81
|
specification_version: 4
|
|
81
82
|
summary: ''
|
|
82
83
|
test_files: []
|