ruby-keepassx 0.2.0beta11
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.coveralls.yml +1 -0
- data/.gitignore +9 -0
- data/.rvmrc +1 -0
- data/.travis.yml +5 -0
- data/Gemfile +5 -0
- data/README.md +28 -0
- data/Rakefile +5 -0
- data/lib/keepassx.rb +56 -0
- data/lib/keepassx/aes_crypt.rb +19 -0
- data/lib/keepassx/database.rb +431 -0
- data/lib/keepassx/entry.rb +155 -0
- data/lib/keepassx/entry_field.rb +27 -0
- data/lib/keepassx/exceptions.rb +4 -0
- data/lib/keepassx/field.rb +205 -0
- data/lib/keepassx/group.rb +138 -0
- data/lib/keepassx/group_field.rb +22 -0
- data/lib/keepassx/header.rb +178 -0
- data/lib/keepassx/item.rb +128 -0
- data/lib/keepassx/utilities.rb +226 -0
- data/ruby-keepassx.gemspec +23 -0
- data/spec/fixtures/test_data_array.yaml +54 -0
- data/spec/fixtures/test_data_hash.yaml +36 -0
- data/spec/fixtures/test_database.kdb +0 -0
- data/spec/keepassx/database_spec.rb +338 -0
- data/spec/keepassx/entry_spec.rb +72 -0
- data/spec/keepassx/group_spec.rb +47 -0
- data/spec/spec_helper.rb +64 -0
- metadata +126 -0
@@ -0,0 +1,22 @@
|
|
1
|
+
module Keepassx
|
2
|
+
class GroupField
|
3
|
+
include Field
|
4
|
+
|
5
|
+
FIELD_TYPES = [
|
6
|
+
[0x0, 'ignored', :null],
|
7
|
+
[0x1, 'groupid', :int],
|
8
|
+
[0x2, 'group_name', :string],
|
9
|
+
[0x3, 'creation_time', :date],
|
10
|
+
[0x4, 'lastmod_time', :date],
|
11
|
+
[0x5, 'lastacc_time', :date],
|
12
|
+
[0x6, 'expire_time', :date],
|
13
|
+
[0x7, 'imageid', :int],
|
14
|
+
[0x8, 'level', :short],
|
15
|
+
[0x9, 'flags', :int],
|
16
|
+
[0xFFFF, 'terminator', :null]
|
17
|
+
]
|
18
|
+
|
19
|
+
attr_reader :name, :data_type, :type_code
|
20
|
+
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,178 @@
|
|
1
|
+
# The keepass file header.
|
2
|
+
#
|
3
|
+
# From the KeePass doc:
|
4
|
+
#
|
5
|
+
# Database header: [DBHDR]
|
6
|
+
#
|
7
|
+
# [ 4 bytes] DWORD dwSignature1 = 0x9AA2D903
|
8
|
+
# [ 4 bytes] DWORD dwSignature2 = 0xB54BFB65
|
9
|
+
# [ 4 bytes] DWORD dwFlags
|
10
|
+
# [ 4 bytes] DWORD dwVersion { Ve.Ve.Mj.Mj:Mn.Mn.Bl.Bl }
|
11
|
+
# [16 bytes] BYTE{16} aMasterSeed
|
12
|
+
# [16 bytes] BYTE{16} aEncryptionIV
|
13
|
+
# [ 4 bytes] DWORD dwGroups Number of groups in database
|
14
|
+
# [ 4 bytes] DWORD dwEntries Number of entries in database
|
15
|
+
# [32 bytes] BYTE{32} aContentsHash SHA-256 hash value of the plain contents
|
16
|
+
# [32 bytes] BYTE{32} aMasterSeed2 Used for the dwKeyEncRounds AES
|
17
|
+
# master key transformations
|
18
|
+
# [ 4 bytes] DWORD dwKeyEncRounds See above; number of transformations
|
19
|
+
#
|
20
|
+
# Notes:
|
21
|
+
#
|
22
|
+
# - dwFlags is a bitmap, which can include:
|
23
|
+
# * PWM_FLAG_SHA2 (1) for SHA-2.
|
24
|
+
# * PWM_FLAG_RIJNDAEL (2) for AES (Rijndael).
|
25
|
+
# * PWM_FLAG_ARCFOUR (4) for ARC4.
|
26
|
+
# * PWM_FLAG_TWOFISH (8) for Twofish.
|
27
|
+
# - aMasterSeed is a salt that gets hashed with the transformed user master key
|
28
|
+
# to form the final database data encryption/decryption key.
|
29
|
+
# * FinalKey = SHA-256(aMasterSeed, TransformedUserMasterKey)
|
30
|
+
# - aEncryptionIV is the initialization vector used by AES/Twofish for
|
31
|
+
# encrypting/decrypting the database data.
|
32
|
+
# - aContentsHash: "plain contents" refers to the database file, minus the
|
33
|
+
# database header, decrypted by FinalKey.
|
34
|
+
# * PlainContents = Decrypt_with_FinalKey(DatabaseFile - DatabaseHeader)
|
35
|
+
module Keepassx
|
36
|
+
class Header
|
37
|
+
|
38
|
+
ENCRYPTION_FLAGS = [
|
39
|
+
[1, 'SHA2'],
|
40
|
+
[2, 'Rijndael'],
|
41
|
+
[2, 'AES'],
|
42
|
+
[4, 'ArcFour'],
|
43
|
+
[8, 'TwoFish']
|
44
|
+
]
|
45
|
+
|
46
|
+
FIELDS = [
|
47
|
+
:signature1,
|
48
|
+
:signature2,
|
49
|
+
:flags,
|
50
|
+
:version,
|
51
|
+
:master_seed,
|
52
|
+
:encryption_iv,
|
53
|
+
:group_number,
|
54
|
+
:entry_number,
|
55
|
+
:contents_hash,
|
56
|
+
:master_seed2,
|
57
|
+
:rounds
|
58
|
+
]
|
59
|
+
|
60
|
+
SIGNATURES = [0x9AA2D903, 0xB54BFB65]
|
61
|
+
|
62
|
+
attr_accessor :group_number, :entry_number, :encryption_iv, :contents_hash
|
63
|
+
attr_reader :signature1, :signature2, :flags, :version, :master_seed,
|
64
|
+
:master_seed2, :rounds
|
65
|
+
|
66
|
+
|
67
|
+
def initialize(header_bytes = nil)
|
68
|
+
if header_bytes.nil?
|
69
|
+
self.signature1, self.signature2 = SIGNATURES
|
70
|
+
self.flags = 3 # SHA2 hashing, AES encryption
|
71
|
+
self.version = 0x30002
|
72
|
+
|
73
|
+
self.master_seed = SecureRandom.random_bytes(16)
|
74
|
+
self.encryption_iv = SecureRandom.random_bytes(16)
|
75
|
+
self.master_seed2 = SecureRandom.random_bytes(32)
|
76
|
+
|
77
|
+
self.group_number = 0
|
78
|
+
self.entry_number = 0
|
79
|
+
self.rounds = 50000
|
80
|
+
else
|
81
|
+
header_bytes = StringIO.new header_bytes
|
82
|
+
self.signature1 = header_bytes.read(4).unpack('L*').first
|
83
|
+
self.signature2 = header_bytes.read(4).unpack('L*').first
|
84
|
+
self.flags = header_bytes.read(4).unpack('L*').first
|
85
|
+
self.version = header_bytes.read(4).unpack('L*').first
|
86
|
+
self.master_seed = header_bytes.read(16)
|
87
|
+
self.encryption_iv = header_bytes.read(16)
|
88
|
+
self.group_number = header_bytes.read(4).unpack('L*').first
|
89
|
+
self.entry_number = header_bytes.read(4).unpack('L*').first
|
90
|
+
self.contents_hash = header_bytes.read(32)
|
91
|
+
self.master_seed2 = header_bytes.read(32)
|
92
|
+
self.rounds = header_bytes.read(4).unpack('L*').first
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
|
97
|
+
def length
|
98
|
+
length = 0
|
99
|
+
FIELDS.each do |field|
|
100
|
+
length += field.length
|
101
|
+
end
|
102
|
+
length
|
103
|
+
end
|
104
|
+
|
105
|
+
|
106
|
+
# Return encoded header
|
107
|
+
#
|
108
|
+
# @return [String] Encoded header representation.
|
109
|
+
def encode
|
110
|
+
[signature1].pack('L*') <<
|
111
|
+
[signature2].pack('L*') <<
|
112
|
+
[flags].pack('L*') <<
|
113
|
+
[version].pack('L*') <<
|
114
|
+
master_seed <<
|
115
|
+
encryption_iv <<
|
116
|
+
[group_number].pack('L*') <<
|
117
|
+
[entry_number].pack('L*') <<
|
118
|
+
contents_hash <<
|
119
|
+
master_seed2 <<
|
120
|
+
[rounds].pack('L*')
|
121
|
+
|
122
|
+
end
|
123
|
+
|
124
|
+
|
125
|
+
def valid?
|
126
|
+
signature1 == SIGNATURES[0] && signature2 == SIGNATURES[1]
|
127
|
+
end
|
128
|
+
|
129
|
+
|
130
|
+
def encryption_type
|
131
|
+
ENCRYPTION_FLAGS.each do |(flag_mask, encryption_type)|
|
132
|
+
return encryption_type if flags & flag_mask
|
133
|
+
end
|
134
|
+
'Unknown'
|
135
|
+
end
|
136
|
+
|
137
|
+
|
138
|
+
def final_key(master_key, keyfile_data=nil)
|
139
|
+
key = Digest::SHA2.new.update(master_key).digest
|
140
|
+
|
141
|
+
if keyfile_data
|
142
|
+
if keyfile_data.size == 64 # Hex encoded key
|
143
|
+
keyfile_hash = [keyfile_data].pack("H*")
|
144
|
+
elsif keyfile_data.size == 32 # Raw key
|
145
|
+
keyfile_hash = keyfile_data
|
146
|
+
else
|
147
|
+
keyfile_hash = Digest::SHA2.new.update(keyfile_data).digest
|
148
|
+
end
|
149
|
+
|
150
|
+
if master_key == ""
|
151
|
+
key = keyfile_hash
|
152
|
+
else
|
153
|
+
key = Digest::SHA2.new.update(key + keyfile_hash).digest
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
aes = OpenSSL::Cipher::Cipher.new('AES-256-ECB')
|
158
|
+
aes.encrypt
|
159
|
+
aes.key = master_seed2
|
160
|
+
aes.padding = 0
|
161
|
+
|
162
|
+
rounds.times do
|
163
|
+
key = aes.update(key) + aes.final
|
164
|
+
end
|
165
|
+
|
166
|
+
key = Digest::SHA2.new.update(key).digest
|
167
|
+
key = Digest::SHA2.new.update(master_seed + key).digest
|
168
|
+
|
169
|
+
end
|
170
|
+
|
171
|
+
|
172
|
+
private
|
173
|
+
|
174
|
+
attr_writer :signature1, :signature2, :flags, :version, :master_seed,
|
175
|
+
:master_seed2, :rounds
|
176
|
+
|
177
|
+
end
|
178
|
+
end
|
@@ -0,0 +1,128 @@
|
|
1
|
+
module Keepassx
|
2
|
+
|
3
|
+
module Item
|
4
|
+
|
5
|
+
|
6
|
+
def initialize _
|
7
|
+
@fields = []
|
8
|
+
end
|
9
|
+
|
10
|
+
def length
|
11
|
+
@fields.map(&:length).reduce(&:+)
|
12
|
+
end
|
13
|
+
|
14
|
+
|
15
|
+
def encode
|
16
|
+
buffer = ''
|
17
|
+
# FIXME: Check if terminator field not in @fields
|
18
|
+
@fields.each do |field|
|
19
|
+
buffer << field.encode
|
20
|
+
end
|
21
|
+
buffer
|
22
|
+
end
|
23
|
+
|
24
|
+
|
25
|
+
# TODO: Remove debug method
|
26
|
+
def dump
|
27
|
+
@fields
|
28
|
+
end
|
29
|
+
|
30
|
+
|
31
|
+
def to_hash
|
32
|
+
result = {}
|
33
|
+
self.class.fields.each do |field|
|
34
|
+
|
35
|
+
filed = field.to_sym
|
36
|
+
if self.respond_to? field
|
37
|
+
data = self.send field
|
38
|
+
# Skip empty value
|
39
|
+
if data.respond_to? :empty?
|
40
|
+
result[field] = data unless data.empty?
|
41
|
+
else
|
42
|
+
unless data.nil?
|
43
|
+
# FIXME: Implement field export method
|
44
|
+
if data.is_a? Time
|
45
|
+
result[field] = data.to_i
|
46
|
+
else
|
47
|
+
result[field] = data
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
# warn "result: #{result}"
|
55
|
+
result
|
56
|
+
end
|
57
|
+
|
58
|
+
|
59
|
+
def terminator= _
|
60
|
+
self.class::FIELD_CLASS.new :name => :terminator
|
61
|
+
end
|
62
|
+
|
63
|
+
|
64
|
+
def inspect
|
65
|
+
output = "<#{self.class} "
|
66
|
+
default_fields.each_key do |field_name|
|
67
|
+
if field_name.eql? :password
|
68
|
+
output << ', password=[FILTERED]'
|
69
|
+
else
|
70
|
+
output << ", #{field_name}=#{self.send(field_name)}" unless
|
71
|
+
field_name.eql? :terminator
|
72
|
+
end
|
73
|
+
end
|
74
|
+
output << '>'
|
75
|
+
end
|
76
|
+
|
77
|
+
|
78
|
+
def to_xml
|
79
|
+
section_name = (self.is_a? Keepassx::Group) && 'group' || 'entry'
|
80
|
+
main_section = REXML::Element.new section_name
|
81
|
+
default_fields.each_key do |field_name|
|
82
|
+
unless field_name.eql? :terminator
|
83
|
+
filed_section = main_section.add_element field_name.to_s
|
84
|
+
filed_section.text = self.send field_name
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
main_section
|
89
|
+
end
|
90
|
+
|
91
|
+
|
92
|
+
private
|
93
|
+
|
94
|
+
def decode(buffer)
|
95
|
+
@fields = []
|
96
|
+
loop do
|
97
|
+
field = self.class::FIELD_CLASS.new(buffer)
|
98
|
+
@fields << field
|
99
|
+
break if field.terminator?
|
100
|
+
end
|
101
|
+
@fields
|
102
|
+
end
|
103
|
+
|
104
|
+
|
105
|
+
def set(name, value)
|
106
|
+
field = @fields.detect { |field| field.name.eql? name.to_s }
|
107
|
+
if field.nil?
|
108
|
+
field = self.class::FIELD_CLASS.new :name => name, :data => value
|
109
|
+
else
|
110
|
+
field.data = value
|
111
|
+
end
|
112
|
+
field
|
113
|
+
end
|
114
|
+
|
115
|
+
|
116
|
+
def get(name)
|
117
|
+
field = @fields.detect { |field| field.name.eql? name.to_s }
|
118
|
+
# if field.nil?
|
119
|
+
# # FIXME: Put proper field name into 'Field doesn't exists' exception
|
120
|
+
# # fail "Field '#{name}' doesn't exists or not yet created"
|
121
|
+
# else
|
122
|
+
# field.data
|
123
|
+
# end
|
124
|
+
field.data unless field.nil? # Return nil, if field doesn't exist
|
125
|
+
end
|
126
|
+
|
127
|
+
end
|
128
|
+
end
|
@@ -0,0 +1,226 @@
|
|
1
|
+
module Keepassx
|
2
|
+
module Utilities
|
3
|
+
|
4
|
+
|
5
|
+
private
|
6
|
+
|
7
|
+
# Organize descendants in proper structure for given group.
|
8
|
+
# @param [Keepassx::Group] group Root group, branch is build for.
|
9
|
+
def build_branch group
|
10
|
+
group_hash = group.to_hash
|
11
|
+
group_entries = entries :group => group
|
12
|
+
descendant_groups = groups :parent => group
|
13
|
+
|
14
|
+
unless group_entries.nil?
|
15
|
+
group_hash[:entries] = []
|
16
|
+
entries.each { |e| group_hash[:entries] << e.to_hash }
|
17
|
+
end
|
18
|
+
|
19
|
+
unless descendant_groups.nil?
|
20
|
+
group_hash[:groups] = []
|
21
|
+
# Recursively build branch for descendant groups
|
22
|
+
descendant_groups.each { |g| group_hash[:groups] << build_branch(g) }
|
23
|
+
end
|
24
|
+
|
25
|
+
group_hash
|
26
|
+
end
|
27
|
+
|
28
|
+
|
29
|
+
def deep_copy opts
|
30
|
+
Marshal.load Marshal.dump opts # Make deep copy
|
31
|
+
end
|
32
|
+
|
33
|
+
|
34
|
+
def decrypt
|
35
|
+
@payload = AESCrypt.decrypt @encrypted_payload, final_key,
|
36
|
+
header.encryption_iv, 'AES-256-CBC'
|
37
|
+
current_checksum = checksum
|
38
|
+
unless current_checksum.eql? header.contents_hash
|
39
|
+
raise Keepassx::HashError.new "Hash test failed, expected " \
|
40
|
+
"#{header.contents_hash.inspect}, got #{current_checksum.inspect}"
|
41
|
+
end
|
42
|
+
|
43
|
+
@payload
|
44
|
+
end
|
45
|
+
|
46
|
+
|
47
|
+
def encrypt
|
48
|
+
@encrypted_payload = AESCrypt.encrypt @payload, final_key,
|
49
|
+
header.encryption_iv, 'AES-256-CBC'
|
50
|
+
end
|
51
|
+
|
52
|
+
|
53
|
+
def read opts
|
54
|
+
read_method = File.respond_to?(:binread) && :binread || :read
|
55
|
+
File.send read_method, opts
|
56
|
+
|
57
|
+
# FIXME: Implement exceptions
|
58
|
+
rescue IOError => e
|
59
|
+
warn ">>>> IOError in database.rb"
|
60
|
+
fail
|
61
|
+
rescue SystemCallError => e
|
62
|
+
warn ">>>> SystemCallError in database.rb"
|
63
|
+
fail
|
64
|
+
end
|
65
|
+
|
66
|
+
|
67
|
+
def final_key
|
68
|
+
fail "No master password specified" if password.nil?
|
69
|
+
|
70
|
+
key_file_data = nil
|
71
|
+
if File.exists? key_file
|
72
|
+
read_method = File.respond_to?(:binread) ? :binread : :read
|
73
|
+
key_file_data = File.send read_method
|
74
|
+
end unless key_file.nil?
|
75
|
+
|
76
|
+
header.final_key(password, key_file_data)
|
77
|
+
end
|
78
|
+
|
79
|
+
|
80
|
+
def initialize_database raw_db
|
81
|
+
if raw_db.empty?
|
82
|
+
@header = Header.new
|
83
|
+
@locked = false
|
84
|
+
else
|
85
|
+
@header = Header.new raw_db[0..124]
|
86
|
+
@encrypted_payload = raw_db[124..-1]
|
87
|
+
end
|
88
|
+
|
89
|
+
@locked
|
90
|
+
end
|
91
|
+
|
92
|
+
|
93
|
+
# Set parents for groups
|
94
|
+
#
|
95
|
+
# @param [Array] list Array of groups.
|
96
|
+
# @return [Array] Updated array of groups.
|
97
|
+
def initialize_groups list
|
98
|
+
|
99
|
+
list.each_with_index do |group, index|
|
100
|
+
|
101
|
+
if index.eql? 0
|
102
|
+
previous_group = nil
|
103
|
+
else
|
104
|
+
previous_group = list[index - 1]
|
105
|
+
end
|
106
|
+
|
107
|
+
# If group is first entry or has level equal 0,
|
108
|
+
# it gets parent set to nil
|
109
|
+
if previous_group.nil? or group.level.eql? 0
|
110
|
+
group.parent = nil
|
111
|
+
|
112
|
+
# If group has level greater than parent's level by one,
|
113
|
+
# it gets parent set to the first previous group with level less
|
114
|
+
# than group's level by one
|
115
|
+
elsif group.level == previous_group.level + 1 or
|
116
|
+
group.level == previous_group.level
|
117
|
+
|
118
|
+
group.parent = previous_group
|
119
|
+
|
120
|
+
# If group has level less than or equal the level of the previous
|
121
|
+
# group and its level is no less than zero, then need to backward
|
122
|
+
# search for the first group which level is less than group's
|
123
|
+
# level by 1 and set it as a parent of the group
|
124
|
+
elsif 0 < group.level and group.level <= previous_group.level
|
125
|
+
group.parent = (index - 2).downto 0 do |i|
|
126
|
+
parent_candidate = list[i]
|
127
|
+
break parent_candidate if parent_candidate.level - 1 == group.level
|
128
|
+
end
|
129
|
+
|
130
|
+
# Invalid level
|
131
|
+
else
|
132
|
+
fail "Unexpected level '#{group.level}' for group '#{group.title}'"
|
133
|
+
end
|
134
|
+
|
135
|
+
end
|
136
|
+
|
137
|
+
@groups = list
|
138
|
+
end
|
139
|
+
|
140
|
+
|
141
|
+
def initialize_payload
|
142
|
+
result = ''
|
143
|
+
@groups.each { |group| result << group.encode }
|
144
|
+
@entries.each { |entry| result << entry.encode }
|
145
|
+
result
|
146
|
+
end
|
147
|
+
|
148
|
+
|
149
|
+
def payload
|
150
|
+
@payload ||= initialize_payload
|
151
|
+
end
|
152
|
+
|
153
|
+
|
154
|
+
# Retrieves last sibling index
|
155
|
+
#
|
156
|
+
# @param [Keepassx::Group] parent Last sibling group.
|
157
|
+
# @return [Integer] index Group index.
|
158
|
+
def last_sibling_index parent
|
159
|
+
|
160
|
+
if groups.empty?
|
161
|
+
return -1
|
162
|
+
|
163
|
+
elsif parent.nil?
|
164
|
+
parent_index = 0
|
165
|
+
sibling_level = 1
|
166
|
+
|
167
|
+
else
|
168
|
+
parent_index = groups.find_index parent
|
169
|
+
sibling_level = parent.level + 1
|
170
|
+
|
171
|
+
end
|
172
|
+
|
173
|
+
fail "Could not find group #{parent.title}" if parent_index.nil?
|
174
|
+
|
175
|
+
(parent_index..(header.group_number - 1)).each do |i|
|
176
|
+
break i unless groups[i].level.eql? sibling_level
|
177
|
+
end
|
178
|
+
|
179
|
+
end
|
180
|
+
|
181
|
+
|
182
|
+
# FIXME: Rename the method
|
183
|
+
# See spec/fixtures/test_data_array.yaml for data example
|
184
|
+
def parse_data_array opts
|
185
|
+
groups, entries = opts[:groups], opts[:entries]
|
186
|
+
|
187
|
+
# Remove groups and entries from options, so new group could be
|
188
|
+
# initialized from incoming Hash
|
189
|
+
fields = Keepassx::Group.fields
|
190
|
+
group_opts = opts.reject { |k, _| !fields.include? k }
|
191
|
+
group = add_group group_opts
|
192
|
+
|
193
|
+
entries.each do |e|
|
194
|
+
entry = e.clone
|
195
|
+
add_entry entry.merge(:group => group)
|
196
|
+
end unless entries.nil?
|
197
|
+
|
198
|
+
# Recursively proceed each child group
|
199
|
+
groups.each { |g| parse_data_array g } unless groups.nil?
|
200
|
+
end
|
201
|
+
|
202
|
+
|
203
|
+
def delete_entry item
|
204
|
+
item = entries.delete item
|
205
|
+
header.entry_number -= 1
|
206
|
+
item
|
207
|
+
end
|
208
|
+
|
209
|
+
|
210
|
+
# Recursively delete group
|
211
|
+
def delete_group item
|
212
|
+
group_entries = entries.select { |e| e.group.equal? item }
|
213
|
+
# Delete entries, which belongs to group
|
214
|
+
group_entries.each { |e| delete_entry e }
|
215
|
+
|
216
|
+
group_ancestors = groups.select { |g| g.parent.equal? item }
|
217
|
+
# Recursively delete ancestor groups
|
218
|
+
group_ancestors.each { |g| delete_group g }
|
219
|
+
|
220
|
+
item = groups.delete item
|
221
|
+
header.group_number -= 1
|
222
|
+
item
|
223
|
+
end
|
224
|
+
|
225
|
+
end
|
226
|
+
end
|