scrypt 3.0.6 → 3.1.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
- checksums.yaml.gz.sig +0 -0
- data/README.md +149 -23
- data/Rakefile +38 -24
- data/ext/scrypt/Rakefile +1 -1
- data/ext/scrypt/warnp.c +7 -1
- data/lib/scrypt/engine.rb +227 -0
- data/lib/scrypt/errors.rb +14 -0
- data/lib/scrypt/password.rb +127 -0
- data/lib/scrypt/scrypt_ext.rb +8 -1
- data/lib/scrypt/security_utils.rb +4 -3
- data/lib/scrypt/version.rb +3 -1
- data/lib/scrypt.rb +7 -266
- data/scrypt.gemspec +29 -24
- data/spec/fixtures/test_vectors.yml +67 -0
- data/spec/scrypt/engine_spec.rb +171 -45
- data/spec/scrypt/ffi_spec.rb +33 -0
- data/spec/scrypt/integration_spec.rb +129 -0
- data/spec/scrypt/password_spec.rb +124 -78
- data/spec/scrypt/utils_spec.rb +53 -10
- data/spec/spec_helper.rb +46 -3
- data/spec/support/shared_examples.rb +46 -0
- data/spec/support/test_helpers.rb +47 -0
- data.tar.gz.sig +0 -0
- metadata +40 -104
- metadata.gz.sig +0 -0
- data/autotest/discover.rb +0 -1
data/spec/scrypt/engine_spec.rb
CHANGED
|
@@ -1,84 +1,210 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'spec_helper'))
|
|
4
|
+
|
|
5
|
+
describe 'The SCrypt engine' do
|
|
6
|
+
it 'calculates a valid cost factor' do
|
|
7
|
+
first = SCrypt::Engine.calibrate(max_time: 0.2)
|
|
6
8
|
expect(SCrypt::Engine.valid_cost?(first)).to equal(true)
|
|
7
9
|
end
|
|
8
10
|
end
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
it "should produce strings" do
|
|
12
|
+
describe 'Generating SCrypt salts' do
|
|
13
|
+
it 'produces strings' do
|
|
13
14
|
expect(SCrypt::Engine.generate_salt).to be_an_instance_of(String)
|
|
14
15
|
end
|
|
15
16
|
|
|
16
|
-
it
|
|
17
|
+
it 'produces random data' do
|
|
17
18
|
expect(SCrypt::Engine.generate_salt).not_to equal(SCrypt::Engine.generate_salt)
|
|
18
19
|
end
|
|
19
20
|
|
|
20
|
-
it
|
|
21
|
+
it 'uses the saved cost factor' do
|
|
21
22
|
# Verify cost is different before saving
|
|
22
|
-
cost = SCrypt::Engine.calibrate(:
|
|
23
|
-
expect(SCrypt::Engine.generate_salt
|
|
23
|
+
cost = SCrypt::Engine.calibrate(max_time: 0.01)
|
|
24
|
+
expect(SCrypt::Engine.generate_salt).not_to start_with(cost)
|
|
24
25
|
|
|
25
|
-
cost = SCrypt::Engine.calibrate!(:
|
|
26
|
-
expect(SCrypt::Engine.generate_salt
|
|
26
|
+
cost = SCrypt::Engine.calibrate!(max_time: 0.01)
|
|
27
|
+
expect(SCrypt::Engine.generate_salt).to start_with(cost)
|
|
27
28
|
end
|
|
28
|
-
end
|
|
29
29
|
|
|
30
|
+
it 'resets calibrated cost when setting new calibration' do
|
|
31
|
+
# Set initial calibration
|
|
32
|
+
first_cost = SCrypt::Engine.calibrate!(max_time: 0.01)
|
|
33
|
+
expect(SCrypt::Engine.calibrated_cost).to eq(first_cost)
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
expect(SCrypt::Engine.
|
|
35
|
+
# Set different calibration
|
|
36
|
+
second_cost = SCrypt::Engine.calibrate!(max_time: 0.02)
|
|
37
|
+
expect(SCrypt::Engine.calibrated_cost).to eq(second_cost)
|
|
38
|
+
expect(SCrypt::Engine.calibrated_cost).not_to eq(first_cost)
|
|
34
39
|
end
|
|
35
40
|
end
|
|
36
41
|
|
|
42
|
+
describe 'Autodetecting of salt cost' do
|
|
43
|
+
it 'works' do
|
|
44
|
+
expect(SCrypt::Engine.autodetect_cost('2a$08$c3$some_salt')).to eq('2a$08$c3$')
|
|
45
|
+
end
|
|
46
|
+
end
|
|
37
47
|
|
|
38
|
-
describe
|
|
39
|
-
|
|
48
|
+
describe 'Generating SCrypt hashes' do
|
|
40
49
|
class MyInvalidSecret
|
|
41
50
|
undef to_s
|
|
42
51
|
end
|
|
43
52
|
|
|
44
|
-
before
|
|
53
|
+
before do
|
|
45
54
|
@salt = SCrypt::Engine.generate_salt
|
|
46
|
-
@password =
|
|
55
|
+
@password = 'woo'
|
|
47
56
|
end
|
|
48
57
|
|
|
49
|
-
it
|
|
58
|
+
it 'produces a string' do
|
|
50
59
|
expect(SCrypt::Engine.hash_secret(@password, @salt)).to be_an_instance_of(String)
|
|
51
60
|
end
|
|
52
61
|
|
|
53
|
-
it
|
|
54
|
-
expect
|
|
62
|
+
it 'raises an InvalidSalt error if the salt is invalid' do
|
|
63
|
+
expect { SCrypt::Engine.hash_secret(@password, 'nino') }.to raise_error(SCrypt::Errors::InvalidSalt)
|
|
55
64
|
end
|
|
56
65
|
|
|
57
|
-
it
|
|
58
|
-
expect
|
|
59
|
-
expect
|
|
60
|
-
expect
|
|
66
|
+
it 'raises an InvalidSecret error if the secret is invalid' do
|
|
67
|
+
expect { SCrypt::Engine.hash_secret(MyInvalidSecret.new, @salt) }.to raise_error(SCrypt::Errors::InvalidSecret)
|
|
68
|
+
expect { SCrypt::Engine.hash_secret(nil, @salt) }.not_to raise_error
|
|
69
|
+
expect { SCrypt::Engine.hash_secret(false, @salt) }.not_to raise_error
|
|
61
70
|
end
|
|
62
71
|
|
|
63
|
-
it
|
|
64
|
-
expect(SCrypt::Engine.hash_secret(false, @salt)).to eq(SCrypt::Engine.hash_secret(
|
|
72
|
+
it 'calls #to_s on the secret and use the return value as the actual secret data' do
|
|
73
|
+
expect(SCrypt::Engine.hash_secret(false, @salt)).to eq(SCrypt::Engine.hash_secret('false', @salt))
|
|
65
74
|
end
|
|
66
75
|
end
|
|
67
76
|
|
|
68
|
-
describe
|
|
69
|
-
it
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
describe 'SCrypt test vectors' do
|
|
78
|
+
it 'matches results of SCrypt function' do
|
|
79
|
+
TEST_VECTORS['scrypt_vectors'].each do |vector|
|
|
80
|
+
next if vector['skip_reason'] # Skip memory-intensive tests
|
|
81
|
+
|
|
82
|
+
result = SCrypt::Engine.scrypt(
|
|
83
|
+
vector['password'],
|
|
84
|
+
vector['salt'],
|
|
85
|
+
vector['n'],
|
|
86
|
+
vector['r'],
|
|
87
|
+
vector['p'],
|
|
88
|
+
vector['key_len']
|
|
89
|
+
).unpack('H*').first
|
|
90
|
+
|
|
91
|
+
expect(result).to eq(vector['expected']), "Failed for: #{vector['description']}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'matches equivalent results sent through hash_secret() function' do
|
|
96
|
+
TEST_VECTORS['hash_secret_vectors'].each do |vector|
|
|
97
|
+
next if vector['skip_reason'] # Skip memory-intensive tests
|
|
98
|
+
|
|
99
|
+
result = SCrypt::Engine.hash_secret(
|
|
100
|
+
vector['password'],
|
|
101
|
+
vector['salt'],
|
|
102
|
+
vector['key_len']
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# hash_secret returns: salt + '$' + hash_digest
|
|
106
|
+
# So we expect: "salt$expected_pattern"
|
|
107
|
+
expected_full_hash = "#{vector['salt']}$#{vector['expected_pattern']}"
|
|
108
|
+
expect(result).to eq(expected_full_hash), "Failed for: #{vector['description']}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
describe 'Input validation' do
|
|
114
|
+
describe '#calibrate' do
|
|
115
|
+
it 'raises ArgumentError for negative max_mem' do
|
|
116
|
+
expect do
|
|
117
|
+
SCrypt::Engine.send(:__sc_calibrate, -1, 0.5, 0.2)
|
|
118
|
+
end.to raise_error(ArgumentError, 'max_mem must be non-negative')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'raises ArgumentError for invalid max_memfrac' do
|
|
122
|
+
expect do
|
|
123
|
+
SCrypt::Engine.send(:__sc_calibrate, 1024, -0.1,
|
|
124
|
+
0.2)
|
|
125
|
+
end.to raise_error(ArgumentError, 'max_memfrac must be between 0 and 1')
|
|
126
|
+
expect do
|
|
127
|
+
SCrypt::Engine.send(:__sc_calibrate, 1024, 1.1,
|
|
128
|
+
0.2)
|
|
129
|
+
end.to raise_error(ArgumentError, 'max_memfrac must be between 0 and 1')
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'raises ArgumentError for non-positive max_time' do
|
|
133
|
+
expect do
|
|
134
|
+
SCrypt::Engine.send(:__sc_calibrate, 1024, 0.5, 0)
|
|
135
|
+
end.to raise_error(ArgumentError, 'max_time must be positive')
|
|
136
|
+
|
|
137
|
+
expect do
|
|
138
|
+
SCrypt::Engine.send(:__sc_calibrate, 1024, 0.5, -0.1)
|
|
139
|
+
end.to raise_error(ArgumentError, 'max_time must be positive')
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
describe '#scrypt' do
|
|
144
|
+
it 'raises ArgumentError for nil secret' do
|
|
145
|
+
expect do
|
|
146
|
+
SCrypt::Engine.send(:__sc_crypt, nil, 'salt', 16, 1, 1, 32)
|
|
147
|
+
end.to raise_error(ArgumentError, 'secret cannot be nil')
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'raises ArgumentError for nil salt' do
|
|
151
|
+
expect do
|
|
152
|
+
SCrypt::Engine.send(:__sc_crypt, 'secret', nil, 16, 1, 1, 32)
|
|
153
|
+
end.to raise_error(ArgumentError, 'salt cannot be nil')
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'raises ArgumentError for non-positive parameters' do
|
|
157
|
+
expect do
|
|
158
|
+
SCrypt::Engine.send(:__sc_crypt, 'secret', 'salt', 0, 1, 1, 32)
|
|
159
|
+
end.to raise_error(ArgumentError, 'cpu_cost must be positive')
|
|
160
|
+
|
|
161
|
+
expect do
|
|
162
|
+
SCrypt::Engine.send(:__sc_crypt, 'secret', 'salt', 16, 0, 1, 32)
|
|
163
|
+
end.to raise_error(ArgumentError, 'memory_cost must be positive')
|
|
164
|
+
|
|
165
|
+
expect do
|
|
166
|
+
SCrypt::Engine.send(:__sc_crypt, 'secret', 'salt', 16, 1, 0, 32)
|
|
167
|
+
end.to raise_error(ArgumentError, 'parallelization must be positive')
|
|
168
|
+
|
|
169
|
+
expect do
|
|
170
|
+
SCrypt::Engine.send(:__sc_crypt, 'secret', 'salt', 16, 1, 1,
|
|
171
|
+
0)
|
|
172
|
+
end.to raise_error(ArgumentError, 'key_len must be positive')
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
describe 'Memory usage calculation' do
|
|
178
|
+
it 'calculates memory usage correctly' do
|
|
179
|
+
cost = '400$8$1$'
|
|
180
|
+
memory = SCrypt::Engine.memory_use(cost)
|
|
181
|
+
n = 0x400
|
|
182
|
+
r = 8
|
|
183
|
+
p = 1
|
|
184
|
+
expected = (128 * r * p) + (256 * r) + (128 * r * n)
|
|
185
|
+
expect(memory).to eq(expected)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
describe 'Calibrated cost management' do
|
|
190
|
+
after do
|
|
191
|
+
# Reset calibrated cost after each test
|
|
192
|
+
SCrypt::Engine.calibrated_cost = nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'initializes have no calibrated cost' do
|
|
196
|
+
SCrypt::Engine.calibrated_cost = nil
|
|
197
|
+
expect(SCrypt::Engine.calibrated_cost).to be_nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it 'stores and retrieve calibrated cost' do
|
|
201
|
+
cost = SCrypt::Engine.calibrate!(max_time: 0.01)
|
|
202
|
+
expect(SCrypt::Engine.calibrated_cost).to eq(cost)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it 'uses calibrated cost in generate_salt when available' do
|
|
206
|
+
cost = SCrypt::Engine.calibrate!(max_time: 0.01)
|
|
207
|
+
salt = SCrypt::Engine.generate_salt
|
|
208
|
+
expect(salt).to start_with(cost)
|
|
83
209
|
end
|
|
84
210
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'spec_helper'))
|
|
4
|
+
|
|
5
|
+
describe 'SCrypt FFI Library Loading' do
|
|
6
|
+
describe 'Extension loading' do
|
|
7
|
+
it 'loads the scrypt extension successfully' do
|
|
8
|
+
# This test verifies that the FFI library loads without error
|
|
9
|
+
# If we get here, the library loaded successfully during require
|
|
10
|
+
expect(SCrypt::Ext).to be_a(Module)
|
|
11
|
+
expect(SCrypt::Ext).to respond_to(:sc_calibrate)
|
|
12
|
+
expect(SCrypt::Ext).to respond_to(:crypto_scrypt)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'has proper FFI function signatures' do
|
|
16
|
+
# Verify that the FFI functions are properly bound
|
|
17
|
+
expect(SCrypt::Ext.method(:sc_calibrate)).to be_a(Method)
|
|
18
|
+
expect(SCrypt::Ext.method(:crypto_scrypt)).to be_a(Method)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
describe 'FFI function behavior' do
|
|
23
|
+
it 'handles basic calibration calls' do
|
|
24
|
+
# Test that the FFI functions are callable
|
|
25
|
+
expect { SCrypt::Engine.calibrate(max_time: 0.01) }.not_to raise_error
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'handles basic scrypt calls' do
|
|
29
|
+
salt = SCrypt::Engine.generate_salt(max_time: 0.01)
|
|
30
|
+
expect { SCrypt::Engine.hash_secret('test', salt) }.not_to raise_error
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'spec_helper'))
|
|
4
|
+
|
|
5
|
+
describe 'SCrypt Integration Tests' do
|
|
6
|
+
describe 'Full password lifecycle' do
|
|
7
|
+
let(:secret) { 'my_super_secret_password' }
|
|
8
|
+
let(:options) { { max_time: 0.1, max_mem: 8 * 1024 * 1024 } }
|
|
9
|
+
|
|
10
|
+
it 'create,s store, and verify passwords correctly' do
|
|
11
|
+
# Create password
|
|
12
|
+
password = SCrypt::Password.create(secret, options)
|
|
13
|
+
expect(password).to be_a(SCrypt::Password)
|
|
14
|
+
expect(password.to_s).to match(/^[0-9a-z]+\$[0-9a-z]+\$[0-9a-z]+\$[A-Za-z0-9]+\$[A-Za-z0-9]+$/)
|
|
15
|
+
|
|
16
|
+
# Verify password
|
|
17
|
+
expect(password == secret).to be(true)
|
|
18
|
+
expect(password == 'wrong_password').to be(false)
|
|
19
|
+
|
|
20
|
+
# Re-instantiate from stored hash
|
|
21
|
+
stored_hash = password.to_s
|
|
22
|
+
recovered_password = SCrypt::Password.new(stored_hash)
|
|
23
|
+
|
|
24
|
+
expect(recovered_password == secret).to be(true)
|
|
25
|
+
expect(recovered_password == 'wrong_password').to be(false)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'handles calibration workflow correctly' do
|
|
29
|
+
# Calibrate for fast testing
|
|
30
|
+
cost = SCrypt::Engine.calibrate!(max_time: 0.05)
|
|
31
|
+
expect(cost).to match(/^[0-9a-z]+\$[0-9a-z]+\$[0-9a-z]+\$$/)
|
|
32
|
+
|
|
33
|
+
# Generate salt using calibrated cost
|
|
34
|
+
salt = SCrypt::Engine.generate_salt
|
|
35
|
+
expect(salt).to start_with(cost)
|
|
36
|
+
|
|
37
|
+
# Hash secret with calibrated parameters
|
|
38
|
+
hash = SCrypt::Engine.hash_secret(secret, salt)
|
|
39
|
+
expect(hash).to be_a(String)
|
|
40
|
+
expect(hash).to include(salt)
|
|
41
|
+
|
|
42
|
+
# Verify the hash
|
|
43
|
+
password = SCrypt::Password.new(hash)
|
|
44
|
+
expect(password == secret).to be(true)
|
|
45
|
+
|
|
46
|
+
# Reset calibration
|
|
47
|
+
SCrypt::Engine.calibrated_cost = nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe 'Cross-compatibility tests' do
|
|
52
|
+
it 'is compatible between Engine and Password classes' do
|
|
53
|
+
# Create using Password class
|
|
54
|
+
password1 = SCrypt::Password.create('test_secret', max_time: 0.05)
|
|
55
|
+
|
|
56
|
+
# Extract components and recreate using Engine
|
|
57
|
+
cost = password1.cost
|
|
58
|
+
salt_with_cost = cost + password1.salt
|
|
59
|
+
hash2 = SCrypt::Engine.hash_secret('test_secret', salt_with_cost, password1.digest.length / 2)
|
|
60
|
+
|
|
61
|
+
# Both should verify the same secret
|
|
62
|
+
password2 = SCrypt::Password.new(hash2)
|
|
63
|
+
expect(password1 == 'test_secret').to be(true)
|
|
64
|
+
expect(password2 == 'test_secret').to be(true)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
describe 'Edge cases and error conditions' do
|
|
69
|
+
it 'handles various secret types' do
|
|
70
|
+
# String secret
|
|
71
|
+
password1 = SCrypt::Password.create('string_secret', max_time: 0.05)
|
|
72
|
+
expect(password1 == 'string_secret').to be(true)
|
|
73
|
+
|
|
74
|
+
# Symbol secret
|
|
75
|
+
password2 = SCrypt::Password.create(:symbol_secret, max_time: 0.05)
|
|
76
|
+
expect(password2 == 'symbol_secret').to be(true)
|
|
77
|
+
|
|
78
|
+
# Numeric secret
|
|
79
|
+
password3 = SCrypt::Password.create(12_345, max_time: 0.05)
|
|
80
|
+
expect(password3 == '12345').to be(true)
|
|
81
|
+
|
|
82
|
+
# Boolean secret
|
|
83
|
+
password4 = SCrypt::Password.create(false, max_time: 0.05)
|
|
84
|
+
expect(password4 == 'false').to be(true)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'handles empty and nil secrets safely' do
|
|
88
|
+
# Empty string
|
|
89
|
+
password1 = SCrypt::Password.create('', max_time: 0.05)
|
|
90
|
+
expect(password1 == '').to be(true)
|
|
91
|
+
|
|
92
|
+
# Nil (converts to empty string)
|
|
93
|
+
password2 = SCrypt::Password.create(nil, max_time: 0.05)
|
|
94
|
+
expect(password2 == '').to be(true)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'validates input parameters' do
|
|
98
|
+
# Invalid hash format
|
|
99
|
+
expect { SCrypt::Password.new('invalid_hash') }.to raise_error(SCrypt::Errors::InvalidHash)
|
|
100
|
+
|
|
101
|
+
# Invalid salt
|
|
102
|
+
expect { SCrypt::Engine.hash_secret('secret', 'invalid_salt') }.to raise_error(SCrypt::Errors::InvalidSalt)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
describe 'Performance and memory tests' do
|
|
107
|
+
it 'respects memory and time constraints' do
|
|
108
|
+
start_time = Time.now
|
|
109
|
+
|
|
110
|
+
# Use very low constraints for fast testing
|
|
111
|
+
password = SCrypt::Password.create('test', max_time: 0.01, max_mem: 1024 * 1024)
|
|
112
|
+
|
|
113
|
+
elapsed_time = Time.now - start_time
|
|
114
|
+
|
|
115
|
+
# Should complete reasonably quickly (allowing some overhead)
|
|
116
|
+
expect(elapsed_time).to be < 1.0
|
|
117
|
+
expect(password == 'test').to be(true)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'calculates memory usage correctly' do
|
|
121
|
+
cost = SCrypt::Engine.calibrate(max_time: 0.05)
|
|
122
|
+
memory_usage = SCrypt::Engine.memory_use(cost)
|
|
123
|
+
|
|
124
|
+
# Memory usage should be a reasonable number
|
|
125
|
+
expect(memory_usage).to be > 0
|
|
126
|
+
expect(memory_usage).to be < 100 * 1024 * 1024 # Less than 100MB
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|