keybox 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGES +14 -0
- data/COPYING +22 -0
- data/README +132 -0
- data/bin/keybox +18 -0
- data/bin/kpg +19 -0
- data/data/chargrams.txt +8432 -0
- data/lib/keybox.rb +29 -0
- data/lib/keybox/application/base.rb +114 -0
- data/lib/keybox/application/password_generator.rb +131 -0
- data/lib/keybox/application/password_safe.rb +410 -0
- data/lib/keybox/cipher.rb +6 -0
- data/lib/keybox/convert.rb +1 -0
- data/lib/keybox/convert/csv.rb +96 -0
- data/lib/keybox/digest.rb +13 -0
- data/lib/keybox/entry.rb +200 -0
- data/lib/keybox/error.rb +5 -0
- data/lib/keybox/password_hash.rb +33 -0
- data/lib/keybox/randomizer.rb +193 -0
- data/lib/keybox/storage.rb +2 -0
- data/lib/keybox/storage/container.rb +307 -0
- data/lib/keybox/storage/record.rb +103 -0
- data/lib/keybox/string_generator.rb +194 -0
- data/lib/keybox/term_io.rb +163 -0
- data/lib/keybox/uuid.rb +86 -0
- data/spec/base_app_spec.rb +56 -0
- data/spec/convert_csv_spec.rb +46 -0
- data/spec/entry_spec.rb +63 -0
- data/spec/keybox_app_spec.rb +268 -0
- data/spec/kpg_app_spec.rb +132 -0
- data/spec/password_hash_spec.rb +11 -0
- data/spec/randomizer_spec.rb +116 -0
- data/spec/storage_container_spec.rb +99 -0
- data/spec/storage_record_spec.rb +63 -0
- data/spec/string_generator_spec.rb +114 -0
- data/spec/uuid_spec.rb +74 -0
- metadata +83 -0
@@ -0,0 +1,307 @@
|
|
1
|
+
require 'keybox/cipher'
|
2
|
+
require 'keybox/digest'
|
3
|
+
require 'keybox/storage/record'
|
4
|
+
require 'keybox/uuid'
|
5
|
+
require 'keybox/randomizer'
|
6
|
+
require 'keybox/error'
|
7
|
+
require 'openssl'
|
8
|
+
require 'yaml'
|
9
|
+
|
10
|
+
module Keybox
|
11
|
+
module Storage
|
12
|
+
##
|
13
|
+
# The container of the Keybox records. The Container itself is
|
14
|
+
# a Keybox::Storage::Record with a few extra methods.
|
15
|
+
#
|
16
|
+
# A instance of a Container is created with a assprhase and a
|
17
|
+
# path to a file. The passphrase can be anything that has a
|
18
|
+
# to_s method.
|
19
|
+
#
|
20
|
+
# container = Keybox::Storage::Container.new("i love ruby", "/tmp/database.yml")
|
21
|
+
#
|
22
|
+
# This will load from the given file with the given passphrase
|
23
|
+
# if the file exists, or it will initalize the container to
|
24
|
+
# accept records.
|
25
|
+
#
|
26
|
+
# The records are held decrypted in memory, so keep that in mind
|
27
|
+
# if that is a concern.
|
28
|
+
#
|
29
|
+
# Add Records
|
30
|
+
#
|
31
|
+
# record = Keybox::Storage::Record.new
|
32
|
+
# record.field1 = "data"
|
33
|
+
# record.field1 = "some more data"
|
34
|
+
#
|
35
|
+
# container << record
|
36
|
+
#
|
37
|
+
# Delete Records
|
38
|
+
#
|
39
|
+
# container.delete(record)
|
40
|
+
#
|
41
|
+
# There is no 'update' record, just delete it and add it.
|
42
|
+
#
|
43
|
+
# Find a record accepts a string and will look in all the
|
44
|
+
# records it contains for anything that matches it. It coerces
|
45
|
+
# strings into +Regexp+ so any regex can be used here too.
|
46
|
+
#
|
47
|
+
# container.find("data")
|
48
|
+
#
|
49
|
+
# Report if the container or any of its records have been
|
50
|
+
# modified:
|
51
|
+
#
|
52
|
+
# container.modified?
|
53
|
+
#
|
54
|
+
# Save the container to its default location:
|
55
|
+
#
|
56
|
+
# container.save
|
57
|
+
#
|
58
|
+
# Or to some other location
|
59
|
+
#
|
60
|
+
# container.save("/some/other/path.yml")
|
61
|
+
#
|
62
|
+
# Direct access to the decrypted records is also available
|
63
|
+
# through the +records+ accessor.
|
64
|
+
#
|
65
|
+
# container.records #=> Array of Keybox::Storage::Record
|
66
|
+
#
|
67
|
+
class Container < Keybox::Storage::Record
|
68
|
+
|
69
|
+
attr_reader :records
|
70
|
+
|
71
|
+
ITERATIONS = 2048
|
72
|
+
def initialize(passphrase,path)
|
73
|
+
super()
|
74
|
+
|
75
|
+
@path = path
|
76
|
+
@passphrase = passphrase
|
77
|
+
@records = []
|
78
|
+
|
79
|
+
if not load_from_file then
|
80
|
+
self.version = Keybox::VERSION
|
81
|
+
|
82
|
+
self.key_calc_iterations = ITERATIONS
|
83
|
+
self.key_digest_salt = Keybox::RandomDevice.random_bytes(32)
|
84
|
+
self.key_digest_algorithm = Keybox::Digest::DEFAULT_ALGORITHM
|
85
|
+
self.key_digest = calculated_key_digest(passphrase)
|
86
|
+
|
87
|
+
self.records_init_vector = Keybox::RandomDevice.random_bytes(16)
|
88
|
+
self.records_cipher_algorithm = Keybox::Cipher::DEFAULT_ALGORITHM
|
89
|
+
|
90
|
+
self.records_encrypted_data = ""
|
91
|
+
self.records_digest_salt = Keybox::RandomDevice.random_bytes(32)
|
92
|
+
self.records_digest_algorithm = Keybox::Digest::DEFAULT_ALGORITHM
|
93
|
+
self.records_digest = ""
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
#
|
98
|
+
# Change the master password of the container
|
99
|
+
#
|
100
|
+
def passphrase=(new_passphrase)
|
101
|
+
@passphrase = new_passphrase
|
102
|
+
self.key_digest_salt = Keybox::RandomDevice.random_bytes(32)
|
103
|
+
self.key_digest = calculated_key_digest(new_passphrase)
|
104
|
+
self.records_init_vector = Keybox::RandomDevice.random_bytes(16)
|
105
|
+
self.records_digest_salt = Keybox::RandomDevice.random_bytes(32)
|
106
|
+
end
|
107
|
+
|
108
|
+
#
|
109
|
+
# load from file, if this is successful then replace the
|
110
|
+
# existing member fields on this instance with the data from
|
111
|
+
# the file
|
112
|
+
#
|
113
|
+
def load_from_file
|
114
|
+
return false unless File.exists?(@path)
|
115
|
+
return false unless tmp = YAML.load_file(@path)
|
116
|
+
@creation_time = tmp.creation_time
|
117
|
+
@modification_time = tmp.modification_time
|
118
|
+
@last_access_time = tmp.last_access_time
|
119
|
+
@data_members = tmp.data_members
|
120
|
+
@uuid = tmp.uuid
|
121
|
+
validate_passphrase
|
122
|
+
decrypt_records
|
123
|
+
validate_decryption
|
124
|
+
load_records
|
125
|
+
@modified = false
|
126
|
+
true
|
127
|
+
end
|
128
|
+
|
129
|
+
#
|
130
|
+
# save the current container to a file
|
131
|
+
#
|
132
|
+
def save(path = @path)
|
133
|
+
calculate_records_digest
|
134
|
+
encrypt_records
|
135
|
+
File.open(path,"w") do |f|
|
136
|
+
f.write(self.to_yaml)
|
137
|
+
end
|
138
|
+
|
139
|
+
# mark everything as not modified
|
140
|
+
@records.each do |record|
|
141
|
+
record.modified = false
|
142
|
+
end
|
143
|
+
@modified = false
|
144
|
+
end
|
145
|
+
|
146
|
+
#
|
147
|
+
# calculate the encryption key from the initial passphrase
|
148
|
+
#
|
149
|
+
def calculated_key(passphrase = @passphrase)
|
150
|
+
key = self.key_digest_salt + passphrase
|
151
|
+
self.key_calc_iterations.times do
|
152
|
+
key = Keybox::Digest::CLASSES[self.key_digest_algorithm].digest(key)
|
153
|
+
end
|
154
|
+
return key
|
155
|
+
end
|
156
|
+
|
157
|
+
#
|
158
|
+
# calculate the key digest of the encryption key
|
159
|
+
#
|
160
|
+
def calculated_key_digest(passphrase)
|
161
|
+
Keybox::Digest::CLASSES[self.key_digest_algorithm].hexdigest(calculated_key(passphrase))
|
162
|
+
end
|
163
|
+
|
164
|
+
#
|
165
|
+
# Add a record to the system
|
166
|
+
#
|
167
|
+
def <<(obj)
|
168
|
+
if obj.respond_to?("needs_container_passphrase?") and obj.needs_container_passphrase? then
|
169
|
+
obj.container_passphrase = @passphrase
|
170
|
+
end
|
171
|
+
@records << obj
|
172
|
+
end
|
173
|
+
|
174
|
+
#
|
175
|
+
# The number of Records in the Container
|
176
|
+
#
|
177
|
+
def length
|
178
|
+
@records.size
|
179
|
+
end
|
180
|
+
|
181
|
+
#
|
182
|
+
# The number of Records in the Container
|
183
|
+
#
|
184
|
+
def size
|
185
|
+
@records.size
|
186
|
+
end
|
187
|
+
|
188
|
+
#
|
189
|
+
# Delete a record from the system, we force a modified flag
|
190
|
+
# here since the underlying Record wasn't 'assigned to' we
|
191
|
+
# have to force modification notification.
|
192
|
+
#
|
193
|
+
def delete(obj)
|
194
|
+
@records.delete(obj)
|
195
|
+
@modified = true
|
196
|
+
end
|
197
|
+
|
198
|
+
#
|
199
|
+
# Search only records that have a +url+ field
|
200
|
+
#
|
201
|
+
def find_by_url(url)
|
202
|
+
find(url,%w(url))
|
203
|
+
end
|
204
|
+
|
205
|
+
#
|
206
|
+
# Search all the records in the database finding any that
|
207
|
+
# match the search string passed in. The Search string is
|
208
|
+
# converted to a Regexp before beginning.
|
209
|
+
#
|
210
|
+
# A list of restricted fields can also be passed in and the
|
211
|
+
# regexp will only be matched against those fields
|
212
|
+
#
|
213
|
+
def find(search_string,restricted_to = nil)
|
214
|
+
regex = Regexp.new(search_string)
|
215
|
+
matches = []
|
216
|
+
@records.each do |record|
|
217
|
+
restricted_to = restricted_to || ( record.fields - %w(password) )
|
218
|
+
record.data_members.each_pair do |k,v|
|
219
|
+
if regex.match(v) and restricted_to.include?(k.to_s) then
|
220
|
+
matches << record
|
221
|
+
break
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
return matches
|
226
|
+
end
|
227
|
+
|
228
|
+
#
|
229
|
+
# See if we are modified, or if any of the records are
|
230
|
+
# modified
|
231
|
+
#
|
232
|
+
def modified?
|
233
|
+
return true if super
|
234
|
+
@records.each do |record|
|
235
|
+
return true if record.modified?
|
236
|
+
end
|
237
|
+
return false
|
238
|
+
end
|
239
|
+
|
240
|
+
private
|
241
|
+
|
242
|
+
#
|
243
|
+
# calculate the key digest of the input pass phrase and
|
244
|
+
# compare that to the digest in the data file. If they are
|
245
|
+
# the same, then the pass phrase should be the correct one
|
246
|
+
# for the data.
|
247
|
+
#
|
248
|
+
def validate_passphrase
|
249
|
+
raise Keybox::ValidationError, "Passphrase digests do not match. The passphrase given does not decrypt the data in this file." unless key_digest == calculated_key_digest(@passphrase)
|
250
|
+
end
|
251
|
+
|
252
|
+
#
|
253
|
+
# encrypt the data in the records and store it in
|
254
|
+
# records_encrypted_data
|
255
|
+
#
|
256
|
+
def encrypt_records
|
257
|
+
cipher = OpenSSL::Cipher::Cipher.new(self.records_cipher_algorithm)
|
258
|
+
cipher.encrypt
|
259
|
+
cipher.key = calculated_key
|
260
|
+
cipher.iv = self.records_init_vector
|
261
|
+
self.records_encrypted_data = cipher.update(@records.to_yaml)
|
262
|
+
self.records_encrypted_data << cipher.final
|
263
|
+
end
|
264
|
+
|
265
|
+
#
|
266
|
+
# decrypt the data in the record_data and store it in
|
267
|
+
# records
|
268
|
+
#
|
269
|
+
def decrypt_records
|
270
|
+
cipher = OpenSSL::Cipher::Cipher.new(self.records_cipher_algorithm)
|
271
|
+
cipher.decrypt
|
272
|
+
cipher.key = calculated_key
|
273
|
+
cipher.iv = self.records_init_vector
|
274
|
+
@decrypted_yaml = cipher.update(self.records_encrypted_data)
|
275
|
+
@decrypted_yaml << cipher.final
|
276
|
+
end
|
277
|
+
|
278
|
+
#
|
279
|
+
# make sure that the decrypted data is validated against its
|
280
|
+
# hash
|
281
|
+
#
|
282
|
+
def validate_decryption
|
283
|
+
digest = Keybox::Digest::CLASSES[self.records_digest_algorithm]
|
284
|
+
hexdigest = digest.hexdigest(self.records_digest_salt + @decrypted_yaml)
|
285
|
+
raise Keybox::ValidationError, "Record digests do not match. The given passphrase does not decrypt the data." unless hexdigest == self.records_digest
|
286
|
+
end
|
287
|
+
|
288
|
+
|
289
|
+
def calculate_records_digest
|
290
|
+
digest = Keybox::Digest::CLASSES[self.records_digest_algorithm]
|
291
|
+
self.records_digest = digest.hexdigest(self.records_digest_salt + @records.to_yaml)
|
292
|
+
end
|
293
|
+
|
294
|
+
def load_records
|
295
|
+
@records = YAML.load(@decrypted_yaml)
|
296
|
+
|
297
|
+
# if a record wants, it can have a reference to the
|
298
|
+
# container.
|
299
|
+
@records.each do |record|
|
300
|
+
if record.respond_to?("needs_container_passphrase?") and record.needs_container_passphrase? then
|
301
|
+
record.container_passphrase = @passphrase
|
302
|
+
end
|
303
|
+
end
|
304
|
+
end
|
305
|
+
end
|
306
|
+
end
|
307
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
module Keybox
|
2
|
+
module Storage
|
3
|
+
|
4
|
+
##
|
5
|
+
# Record is very similar to an OStruct but it keeps track of the
|
6
|
+
# last access, creation and modification times of the object.
|
7
|
+
# Each Record also has a UUID for unique identification.
|
8
|
+
#
|
9
|
+
# rec = Keybox::Storage::Record.new
|
10
|
+
#
|
11
|
+
# rec.modified? # => false
|
12
|
+
# rec.some_field = "some value" # => "some value"
|
13
|
+
# rec.modified? # => true
|
14
|
+
# rec.modification_time # => Time
|
15
|
+
# rec.data_members # => { :some_field => "some value" }
|
16
|
+
# rec.uuid.to_s # => "4b25074d-d3c5-23cd-bf9f-e28d8c8d453d"
|
17
|
+
#
|
18
|
+
# The +creation_time+, +modification_time+ and +last_access_time+ of
|
19
|
+
# each field in the Record is recorded.
|
20
|
+
#
|
21
|
+
# The UUID is create when the class is instantiated.
|
22
|
+
#
|
23
|
+
class Record
|
24
|
+
|
25
|
+
attr_reader :creation_time
|
26
|
+
attr_reader :modification_time
|
27
|
+
attr_reader :last_access_time
|
28
|
+
attr_reader :uuid
|
29
|
+
attr_reader :data_members
|
30
|
+
|
31
|
+
PROTECTED_METHODS = [ :creation_time=, :modification_time=, :last_access_time=,
|
32
|
+
:uuid=, :data_members=, :modified, ]
|
33
|
+
def initialize
|
34
|
+
@creation_time = Time.now
|
35
|
+
@modification_time = @creation_time.dup
|
36
|
+
@last_access_time = @creation_time.dup
|
37
|
+
@uuid = Keybox::UUID.new
|
38
|
+
@data_members = Hash.new
|
39
|
+
@modified = false
|
40
|
+
end
|
41
|
+
|
42
|
+
def modified?
|
43
|
+
# since this class can be loaded from a YAML file and
|
44
|
+
# modified is not stored in the serialized format, if
|
45
|
+
# @modified is not initialized, initialize it.
|
46
|
+
if not instance_variables.include?("@modified") then
|
47
|
+
@modified = false
|
48
|
+
end
|
49
|
+
@modified
|
50
|
+
end
|
51
|
+
|
52
|
+
# this is here so that after this class has been serialized
|
53
|
+
# to a file the container can mark it as clean. The class
|
54
|
+
# should take care of marking itself dirty.
|
55
|
+
def modified=(value)
|
56
|
+
@modified = value
|
57
|
+
end
|
58
|
+
|
59
|
+
def method_missing(method_id, *args)
|
60
|
+
method_name = method_id.id2name
|
61
|
+
member_sym = method_name.gsub(/=/,'').to_sym
|
62
|
+
|
63
|
+
# guard against assigning to the time data members and
|
64
|
+
# the data_members element
|
65
|
+
if PROTECTED_METHODS.include?(method_id) then
|
66
|
+
raise NoMethodError, "invalid method #{method_name} for #{self.class.name}", caller(1)
|
67
|
+
end
|
68
|
+
|
69
|
+
# if the method ends with '=' and has a single argument,
|
70
|
+
# then convert the name to the appropriate data member
|
71
|
+
# and store the argument in the hash.
|
72
|
+
if method_name[-1].chr == "=" then
|
73
|
+
raise ArgumentError, "'#{method_name}' requires one and only one argument", caller(1) unless args.size == 1
|
74
|
+
@data_members[member_sym] = args[0]
|
75
|
+
@modified = true
|
76
|
+
@modification_time = Time.now
|
77
|
+
@last_access_time = @modification_time.dup
|
78
|
+
elsif args.size == 0 then
|
79
|
+
@last_access_time = Time.now
|
80
|
+
@data_members[member_sym]
|
81
|
+
else
|
82
|
+
raise NoMethodError, "undefined method #{method_name} for #{self.class.name}", caller(1)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def to_yaml_properties
|
87
|
+
%w{ @creation_time @modification_time @last_access_time @data_members @uuid }
|
88
|
+
end
|
89
|
+
|
90
|
+
def ==(other)
|
91
|
+
self.eql?(other)
|
92
|
+
end
|
93
|
+
|
94
|
+
def eql?(other)
|
95
|
+
if other.kind_of?(Keybox::Storage::Record) then
|
96
|
+
self.uuid == other.uuid
|
97
|
+
else
|
98
|
+
self.uuid == other
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,194 @@
|
|
1
|
+
require 'keybox/randomizer'
|
2
|
+
|
3
|
+
module Keybox
|
4
|
+
|
5
|
+
# base class for string generation. A string generator can be
|
6
|
+
# called serially to generate a new string in chunks, or all at once
|
7
|
+
# to generate one of a certain size.
|
8
|
+
class StringGenerator
|
9
|
+
attr_accessor :chunks
|
10
|
+
attr_accessor :min_length
|
11
|
+
attr_accessor :max_length
|
12
|
+
attr_accessor :autoclear
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@chunks = Array.new
|
16
|
+
@min_length = 8
|
17
|
+
@max_length = 10
|
18
|
+
@randomizer = Keybox::Randomizer.new
|
19
|
+
@autoclear = true
|
20
|
+
end
|
21
|
+
|
22
|
+
def generate
|
23
|
+
clear if autoclear
|
24
|
+
generate_chunk until valid?
|
25
|
+
self.to_s
|
26
|
+
end
|
27
|
+
|
28
|
+
def clear
|
29
|
+
@chunks.clear
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_s
|
33
|
+
@chunks.join('')[0...@max_length]
|
34
|
+
end
|
35
|
+
|
36
|
+
def valid?
|
37
|
+
raise Keybox::ValidationError, "max_length (#{max_length}) must be greater than or equal to min_length (#{min_length})", caller if max_length < min_length
|
38
|
+
@chunks.join('').size >= @min_length
|
39
|
+
end
|
40
|
+
|
41
|
+
def generate_chunk
|
42
|
+
raise Keybox::KeyboxError, "generate_chunk Not Implemented"
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
|
47
|
+
# CharGramGenerator emits a sequence that is a CharGram of length N.
|
48
|
+
# The ngrams can be the default ones that ship with keybox or an
|
49
|
+
# array passed in.
|
50
|
+
class CharGramGenerator < StringGenerator
|
51
|
+
attr_reader :pool
|
52
|
+
|
53
|
+
|
54
|
+
def initialize(chargram_list = nil)
|
55
|
+
super()
|
56
|
+
wordlist = chargram_list || load_default_chargram_list
|
57
|
+
@pool = Hash.new
|
58
|
+
wordlist.each do |word|
|
59
|
+
letters = word.split('')
|
60
|
+
letter_set = @pool[letters.first] ||= Array.new
|
61
|
+
letter_set << word
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def size
|
66
|
+
@pool.inject(0) { |sum,h| sum + h[1].size }
|
67
|
+
end
|
68
|
+
|
69
|
+
# return a random chargram from the pool indidcated by the last
|
70
|
+
# character of the last emitted item
|
71
|
+
def generate_chunk
|
72
|
+
pool_key = ""
|
73
|
+
if @chunks.size > 0 then
|
74
|
+
pool_key = @chunks.pop
|
75
|
+
else
|
76
|
+
pool_key = @randomizer.pick_one_from(@pool.keys)
|
77
|
+
end
|
78
|
+
new_chunk = @randomizer.pick_one_from(@pool[pool_key])
|
79
|
+
@chunks.concat(new_chunk.split(//))
|
80
|
+
new_chunk
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def load_default_chargram_list
|
86
|
+
list = []
|
87
|
+
File.open(File.join(Keybox::APP_DATA_DIR,"chargrams.txt")) do |f|
|
88
|
+
f.each_line do |line|
|
89
|
+
next if line =~ /^#/
|
90
|
+
list << line.rstrip
|
91
|
+
end
|
92
|
+
end
|
93
|
+
list
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
module SymbolSet
|
98
|
+
LOWER_ASCII = ("a".."z").to_a
|
99
|
+
UPPER_ASCII = ("A".."Z").to_a
|
100
|
+
NUMERAL_ASCII = ("0".."9").to_a
|
101
|
+
SPECIAL_ASCII = ("!".."/").to_a + (":".."@").to_a + %w( [ ] ^ _ { } | ~ )
|
102
|
+
ALL = LOWER_ASCII + UPPER_ASCII + NUMERAL_ASCII + SPECIAL_ASCII
|
103
|
+
|
104
|
+
MAPPING = {
|
105
|
+
"lower" => LOWER_ASCII,
|
106
|
+
"upper" => UPPER_ASCII,
|
107
|
+
"numerical" => NUMERAL_ASCII,
|
108
|
+
"special" => SPECIAL_ASCII,
|
109
|
+
"all" => ALL
|
110
|
+
}
|
111
|
+
end
|
112
|
+
|
113
|
+
#
|
114
|
+
# SymbolSetGenerator uses symbol sets and emits a single character
|
115
|
+
# from the aggregated symobl sets that the instance is wrapping.
|
116
|
+
#
|
117
|
+
# That is When a SymbolSetGenerator is instantiated, it can take a
|
118
|
+
# list of symbol sets (a-z, A-Z, 0-9) etc as parameters. Those sets
|
119
|
+
# are merged and when 'emit' is called a string of length 1 is
|
120
|
+
# returned from the aggregated symbols
|
121
|
+
#
|
122
|
+
class SymbolSetGenerator < StringGenerator
|
123
|
+
include SymbolSet
|
124
|
+
|
125
|
+
attr_accessor :required_sets
|
126
|
+
|
127
|
+
def initialize(set = ALL)
|
128
|
+
super()
|
129
|
+
@symbols = set.flatten.uniq
|
130
|
+
@required_sets = []
|
131
|
+
end
|
132
|
+
|
133
|
+
# every time we access the symbols set we need to make sure that
|
134
|
+
# the required symbols are a part of it, and if they aren't then
|
135
|
+
# make sure they are.
|
136
|
+
def symbols
|
137
|
+
if @required_sets.size > 0 then
|
138
|
+
if not @symbols.include?(@required_sets.first[0]) then
|
139
|
+
@symbols << @required_sets
|
140
|
+
@symbols.flatten!
|
141
|
+
@symbols.uniq!
|
142
|
+
end
|
143
|
+
end
|
144
|
+
@symbols
|
145
|
+
end
|
146
|
+
|
147
|
+
def required_generated?
|
148
|
+
result = true
|
149
|
+
@required_sets.each do |set|
|
150
|
+
set_found = false
|
151
|
+
@chunks.each do |chunk|
|
152
|
+
if set.include?(chunk) then
|
153
|
+
set_found = true
|
154
|
+
break
|
155
|
+
end
|
156
|
+
end
|
157
|
+
result = (result and set_found)
|
158
|
+
end
|
159
|
+
return result
|
160
|
+
end
|
161
|
+
|
162
|
+
# we force generation of the required sets at the beginning the
|
163
|
+
# first time we are called.
|
164
|
+
def generate_chunk
|
165
|
+
chunk = ""
|
166
|
+
if required_generated? then
|
167
|
+
@chunks << @randomizer.pick_one_from(symbols)
|
168
|
+
chunk = @chunks.last
|
169
|
+
else
|
170
|
+
req = generate_required
|
171
|
+
@chunks.concat(req)
|
172
|
+
chunk = req.join('')
|
173
|
+
end
|
174
|
+
return chunk
|
175
|
+
end
|
176
|
+
|
177
|
+
# valid for the symbol set generator means the parent classes
|
178
|
+
# validity and that we have in the
|
179
|
+
def valid?
|
180
|
+
super and required_generated?
|
181
|
+
end
|
182
|
+
|
183
|
+
private
|
184
|
+
|
185
|
+
# generate symbols from the required sets
|
186
|
+
def generate_required
|
187
|
+
req = []
|
188
|
+
required_sets.each do |set|
|
189
|
+
req << @randomizer.pick_one_from(set)
|
190
|
+
end
|
191
|
+
return req
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|