ruby-activeldap 0.7.4 → 0.8.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.
Files changed (74) hide show
  1. data/CHANGES +375 -0
  2. data/COPYING +340 -0
  3. data/LICENSE +58 -0
  4. data/Manifest.txt +33 -0
  5. data/README +63 -0
  6. data/Rakefile +37 -0
  7. data/TODO +31 -0
  8. data/benchmark/bench-al.rb +152 -0
  9. data/lib/{activeldap.rb → active_ldap.rb} +280 -263
  10. data/lib/active_ldap/adaptor/base.rb +29 -0
  11. data/lib/active_ldap/adaptor/ldap.rb +466 -0
  12. data/lib/active_ldap/association/belongs_to.rb +38 -0
  13. data/lib/active_ldap/association/belongs_to_many.rb +40 -0
  14. data/lib/active_ldap/association/collection.rb +80 -0
  15. data/lib/active_ldap/association/has_many.rb +48 -0
  16. data/lib/active_ldap/association/has_many_wrap.rb +56 -0
  17. data/lib/active_ldap/association/proxy.rb +89 -0
  18. data/lib/active_ldap/associations.rb +162 -0
  19. data/lib/active_ldap/attributes.rb +199 -0
  20. data/lib/active_ldap/base.rb +1343 -0
  21. data/lib/active_ldap/callbacks.rb +19 -0
  22. data/lib/active_ldap/command.rb +46 -0
  23. data/lib/active_ldap/configuration.rb +96 -0
  24. data/lib/active_ldap/connection.rb +137 -0
  25. data/lib/{activeldap → active_ldap}/ldap.rb +1 -1
  26. data/lib/active_ldap/object_class.rb +70 -0
  27. data/lib/active_ldap/schema.rb +258 -0
  28. data/lib/{activeldap → active_ldap}/timeout.rb +0 -0
  29. data/lib/{activeldap → active_ldap}/timeout_stub.rb +0 -0
  30. data/lib/active_ldap/user_password.rb +92 -0
  31. data/lib/active_ldap/validations.rb +78 -0
  32. data/rails/plugin/active_ldap/README +54 -0
  33. data/rails/plugin/active_ldap/init.rb +6 -0
  34. data/test/TODO +2 -0
  35. data/test/al-test-utils.rb +337 -0
  36. data/test/command.rb +62 -0
  37. data/test/config.yaml +8 -0
  38. data/test/config.yaml.sample +6 -0
  39. data/test/run-test.rb +17 -0
  40. data/test/test-unit-ext.rb +2 -0
  41. data/test/test_associations.rb +334 -0
  42. data/test/test_attributes.rb +71 -0
  43. data/test/test_base.rb +345 -0
  44. data/test/test_base_per_instance.rb +32 -0
  45. data/test/test_bind.rb +53 -0
  46. data/test/test_callback.rb +35 -0
  47. data/test/test_connection.rb +38 -0
  48. data/test/test_connection_per_class.rb +50 -0
  49. data/test/test_find.rb +36 -0
  50. data/test/test_groupadd.rb +50 -0
  51. data/test/test_groupdel.rb +46 -0
  52. data/test/test_groupls.rb +107 -0
  53. data/test/test_groupmod.rb +51 -0
  54. data/test/test_lpasswd.rb +75 -0
  55. data/test/test_object_class.rb +32 -0
  56. data/test/test_reflection.rb +173 -0
  57. data/test/test_schema.rb +166 -0
  58. data/test/test_user.rb +209 -0
  59. data/test/test_user_password.rb +93 -0
  60. data/test/test_useradd-binary.rb +59 -0
  61. data/test/test_useradd.rb +55 -0
  62. data/test/test_userdel.rb +48 -0
  63. data/test/test_userls.rb +86 -0
  64. data/test/test_usermod-binary-add-time.rb +62 -0
  65. data/test/test_usermod-binary-add.rb +61 -0
  66. data/test/test_usermod-binary-del.rb +64 -0
  67. data/test/test_usermod-lang-add.rb +57 -0
  68. data/test/test_usermod.rb +56 -0
  69. data/test/test_validation.rb +38 -0
  70. metadata +94 -21
  71. data/lib/activeldap/associations.rb +0 -170
  72. data/lib/activeldap/base.rb +0 -1456
  73. data/lib/activeldap/configuration.rb +0 -59
  74. data/lib/activeldap/schema2.rb +0 -217
File without changes
File without changes
@@ -0,0 +1,92 @@
1
+ require 'English'
2
+ require 'base64'
3
+ require 'md5'
4
+ require 'sha1'
5
+
6
+ module ActiveLdap
7
+ module UserPassword
8
+ module_function
9
+ def valid?(password, hashed_password)
10
+ unless /^\{([A-Z][A-Z\d]+)\}/ =~ hashed_password
11
+ raise ArgumentError, "Invalid hashed password"
12
+ end
13
+ type = $1
14
+ hashed_password_without_type = $POSTMATCH
15
+ normalized_type = type.downcase
16
+ unless respond_to?(normalized_type)
17
+ raise ArgumentError, "Unknown Hash type #{type}"
18
+ end
19
+ salt_extractor = "extract_salt_for_#{normalized_type}"
20
+ if respond_to?(salt_extractor)
21
+ salt = send(salt_extractor, hashed_password_without_type)
22
+ if salt.nil?
23
+ raise ArgumentError, "Can't extract salt from hashed password"
24
+ end
25
+ generated_password = send(normalized_type, password, salt)
26
+ else
27
+ generated_password = send(normalized_type, password)
28
+ end
29
+ hashed_password == generated_password
30
+ end
31
+
32
+ def crypt(password, salt=nil)
33
+ salt ||= "$1$#{Salt.generate(8)}"
34
+ "{CRYPT}#{password.crypt(salt)}"
35
+ end
36
+
37
+ def extract_salt_for_crypt(crypted_password)
38
+ if /^\$1\$/ =~ crypted_password
39
+ $MATCH + $POSTMATCH[0, 8].sub(/\$.*/, '') + "$"
40
+ else
41
+ crypted_password[0, 2]
42
+ end
43
+ end
44
+
45
+ def md5(password)
46
+ "{MD5}#{Base64.encode64(MD5.md5(password).digest).chomp}"
47
+ end
48
+
49
+ def smd5(password, salt=nil)
50
+ if salt and salt.size != 4
51
+ raise ArgumentError.new("salt size must be == 4")
52
+ end
53
+ salt ||= Salt.generate(4)
54
+ md5_hash_with_salt = "#{MD5.md5(password + salt).digest}#{salt}"
55
+ "{SMD5}#{Base64.encode64(md5_hash_with_salt).chomp}"
56
+ end
57
+
58
+ def extract_salt_for_smd5(smd5ed_password)
59
+ Base64.decode64(smd5ed_password)[-4, 4]
60
+ end
61
+
62
+ def sha(password)
63
+ "{SHA}#{Base64.encode64(SHA1.sha1(password).digest).chomp}"
64
+ end
65
+
66
+ def ssha(password, salt=nil)
67
+ if salt and salt.size != 4
68
+ raise ArgumentError.new("salt size must be == 4")
69
+ end
70
+ salt ||= Salt.generate(4)
71
+ sha1_hash_with_salt = "#{SHA1.sha1(password + salt).digest}#{salt}"
72
+ "{SSHA}#{Base64.encode64(sha1_hash_with_salt).chomp}"
73
+ end
74
+
75
+ def extract_salt_for_ssha(sshaed_password)
76
+ extract_salt_for_smd5(sshaed_password)
77
+ end
78
+
79
+ module Salt
80
+ CHARS = ['.', '/', '0'..'9', 'A'..'Z', 'a'..'z'].collect do |x|
81
+ x.to_a
82
+ end.flatten
83
+
84
+ module_function
85
+ def generate(length)
86
+ salt = ""
87
+ length.times {salt << CHARS[rand(CHARS.length)]}
88
+ salt
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,78 @@
1
+ require 'active_record/validations'
2
+
3
+ module ActiveLdap
4
+ module Validations
5
+ def self.append_features(base)
6
+ super
7
+
8
+ base.class_eval do
9
+ alias_method :new_record?, :new_entry?
10
+ include ActiveRecord::Validations
11
+
12
+ validate :validate_required_values
13
+
14
+ class << self
15
+ alias_method :evaluate_condition_for_active_record,
16
+ :evaluate_condition
17
+ def evaluate_condition(condition, entry)
18
+ evaluate_condition_for_active_record(condition, entry)
19
+ rescue ActiveRecord::ActiveRecordError
20
+ raise Error, $!.message
21
+ end
22
+ end
23
+
24
+ alias_method :save_with_validation_for_active_record!,
25
+ :save_with_validation!
26
+ def save_with_validation!
27
+ save_with_validation_for_active_record!
28
+ rescue ActiveRecord::RecordInvalid
29
+ raise EntryInvalid, $!.message
30
+ end
31
+ alias_method :save!, :save_with_validation!
32
+
33
+ def valid?
34
+ ensure_apply_object_class
35
+ super
36
+ end
37
+
38
+ # validate_required_values
39
+ #
40
+ # Basic validation:
41
+ # - Verify that every 'MUST' specified in the schema has a value defined
42
+ def validate_required_values
43
+ logger.debug {"stub: validate_required_values called"}
44
+
45
+ # Make sure all MUST attributes have a value
46
+ @musts.each do |object_class, attributes|
47
+ attributes.each do |required_attribute|
48
+ # Normalize to ensure we catch schema problems
49
+ real_name = to_real_attribute_name(required_attribute)
50
+ # # Set default if it wasn't yet set.
51
+ # @data[real_name] ||= [] # need?
52
+ value = @data[real_name] || []
53
+ # Check for missing requirements.
54
+ if value.empty?
55
+ aliases = schema.attribute_aliases(real_name) - [real_name]
56
+ message = "is required attribute "
57
+ unless aliases.empty?
58
+ message << "(aliases: #{aliases.join(', ')}) "
59
+ end
60
+ message << "by objectClass '#{object_class}'"
61
+ errors.add(real_name, message)
62
+ end
63
+ end
64
+ end
65
+ logger.debug {"stub: validate_required_values finished"}
66
+ end
67
+
68
+ private
69
+ alias_method :run_validations_for_active_record, :run_validations
70
+ def run_validations(validation_method)
71
+ run_validations_for_active_record(validation_method)
72
+ rescue ActiveRecord::ActiveRecordError
73
+ raise Error, $!.message
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,54 @@
1
+ = ActiveLdap plugin for Ruby on Rails
2
+
3
+ == Setup
4
+
5
+ You need to write RAILS_ROOT/config/ldap.yml like the following:
6
+
7
+ development:
8
+ host: 127.0.0.1
9
+ port: 389
10
+ base: dc=devel,dc=local,dc=net
11
+ bind_dn: cn=admin,dc=local,dc=net
12
+ password: secret
13
+
14
+ test:
15
+ host: 127.0.0.1
16
+ port: 389
17
+ base: dc=test,dc=local,dc=net
18
+ bind_dn: cn=admin,dc=local,dc=net
19
+ password: secret
20
+
21
+ production:
22
+ host: 127.0.0.1
23
+ port: 389
24
+ base: dc=production,dc=local,dc=net
25
+ bind_dn: cn=admin,dc=local,dc=net
26
+ password: secret
27
+
28
+ == Model
29
+
30
+ Here is some examples.
31
+
32
+ app/model/member.rb:
33
+ class Member < ActiveLdap::Base
34
+ ldap_mapping :dn_attribute => 'uid',
35
+ :classes => ['person', 'posixAccount']
36
+ belongs_to :primary_group, :class => "Group",
37
+ :foreign_key => "gidNumber", :primary_key => "gidNumber"
38
+ belongs_to :groups, :many => 'memberUid'
39
+ end
40
+
41
+ app/model/group.rb:
42
+ class Group < ActiveLdap::Base
43
+ ldap_mapping :dn_attribute => "cn", :classes => ['posixGroup']
44
+ has_many :members, :wrap => "memberUid"
45
+ has_many :primary_members,
46
+ :foreign_key => 'gidNumber',
47
+ :primary_key => 'gidNumber'
48
+ end
49
+
50
+ app/model/ou.rb:
51
+ class Ou < ActiveLdap::Base
52
+ ldap_mapping :prefix => "",
53
+ :classes => ["top", "organizationalUnit"]
54
+ end
@@ -0,0 +1,6 @@
1
+ require_dependency 'active_ldap'
2
+ ActiveLdap::Base.logger ||= RAILS_DEFAULT_LOGGER
3
+ ldap_configuration_file = File.join(RAILS_ROOT, 'config', 'ldap.yml')
4
+ configurations = YAML::load(ERB.new(IO.read(ldap_configuration_file)).result)
5
+ ActiveLdap::Base.configurations = configurations
6
+ ActiveLdap::Base.establish_connection
data/test/TODO ADDED
@@ -0,0 +1,2 @@
1
+
2
+ - Perform explicit testing of Schema2, ActiveLdap::Base, etc
@@ -0,0 +1,337 @@
1
+ require 'test/unit'
2
+ require 'test-unit-ext'
3
+
4
+ require 'erb'
5
+ require 'yaml'
6
+ require 'socket'
7
+ require 'openssl'
8
+ require 'rbconfig'
9
+
10
+ require 'active_ldap'
11
+
12
+ require File.join(File.expand_path(File.dirname(__FILE__)), "command")
13
+
14
+ LDAP_ENV = "test" unless defined?(LDAP_ENV)
15
+
16
+ module AlTestUtils
17
+ def self.included(base)
18
+ base.class_eval do
19
+ include Config
20
+ include Connection
21
+ include Populate
22
+ include TemporaryEntry
23
+ include CommandSupport
24
+ end
25
+ end
26
+
27
+ module Config
28
+ def setup
29
+ super
30
+ @base_dir = File.expand_path(File.dirname(__FILE__))
31
+ @top_dir = File.expand_path(File.join(@base_dir, ".."))
32
+ @example_dir = File.join(@top_dir, "examples")
33
+ @config_file = File.join(File.dirname(__FILE__), "config.yaml")
34
+ ActiveLdap::Base.configurations = read_config
35
+ end
36
+
37
+ def teardown
38
+ super
39
+ end
40
+
41
+ def current_configuration
42
+ ActiveLdap::Base.configurations[LDAP_ENV]
43
+ end
44
+
45
+ def read_config
46
+ unless File.exist?(@config_file)
47
+ raise "config file for testing doesn't exist: #{@config_file}"
48
+ end
49
+ YAML.load(ERB.new(File.read(@config_file)).result)
50
+ end
51
+ end
52
+
53
+ module Connection
54
+ def setup
55
+ super
56
+ ActiveLdap::Base.establish_connection
57
+ end
58
+
59
+ def teardown
60
+ ActiveLdap::Base.clear_active_connections!
61
+ super
62
+ end
63
+ end
64
+
65
+ module Populate
66
+ def setup
67
+ @dumped_data = nil
68
+ super
69
+ begin
70
+ @dumped_data = ActiveLdap::Base.dump(:scope => :sub)
71
+ rescue ActiveLdap::ConnectionError
72
+ end
73
+ ActiveLdap::Base.delete_all(nil, :scope => :sub)
74
+ populate
75
+ end
76
+
77
+ def teardown
78
+ if @dumped_data
79
+ ActiveLdap::Base.establish_connection
80
+ ActiveLdap::Base.delete_all(nil, :scope => :sub)
81
+ ActiveLdap::Base.load(@dumped_data)
82
+ end
83
+ super
84
+ end
85
+
86
+ def populate
87
+ populate_base
88
+ populate_ou
89
+ populate_user_class
90
+ populate_group_class
91
+ populate_associations
92
+ end
93
+
94
+ def populate_base
95
+ unless ActiveLdap::Base.search(:scope => :base).empty?
96
+ return
97
+ end
98
+
99
+ suffixes = []
100
+ ActiveLdap::Base.base.split(/,/).reverse_each do |suffix|
101
+ prefix = suffixes.join(",")
102
+ suffixes.unshift(suffix)
103
+ name, value = suffix.split(/=/, 2)
104
+ next unless name == "dc"
105
+ dc_class = Class.new(ActiveLdap::Base)
106
+ dc_class.ldap_mapping :dn_attribute => "dc",
107
+ :prefix => "",
108
+ :scope => :base,
109
+ :classes => ["top", "dcObject", "organization"]
110
+ dc_class.base = prefix
111
+ next if dc_class.exists?(value, :prefix => "dc=#{value}")
112
+ dc = dc_class.new(value)
113
+ dc.o = dc.dc
114
+ dc.save
115
+ end
116
+ end
117
+
118
+ def ou_class(prefix="")
119
+ ou_class = Class.new(ActiveLdap::Base)
120
+ ou_class.ldap_mapping :dn_attribute => "ou",
121
+ :prefix => prefix,
122
+ :classes => ["top", "organizationalUnit"]
123
+ ou_class
124
+ end
125
+
126
+ def populate_ou
127
+ %w(Users Groups).each do |name|
128
+ make_ou(name)
129
+ end
130
+ end
131
+
132
+ def make_ou(name)
133
+ ou_class.new(name).save
134
+ end
135
+
136
+ def populate_user_class
137
+ @user_class = Class.new(ActiveLdap::Base)
138
+ @user_class.ldap_mapping :dn_attribute => "uid",
139
+ :prefix => "ou=Users",
140
+ :scope => :sub,
141
+ :classes => ["posixAccount", "person"]
142
+ end
143
+
144
+ def populate_group_class
145
+ @group_class = Class.new(ActiveLdap::Base)
146
+ @group_class.ldap_mapping :prefix => "ou=Groups",
147
+ :scope => :sub,
148
+ :classes => ["posixGroup"]
149
+ end
150
+
151
+ def populate_associations
152
+ @user_class.belongs_to :groups, :many => "memberUid"
153
+ @user_class.belongs_to :primary_group,
154
+ :foreign_key => "gidNumber",
155
+ :primary_key => "gidNumber"
156
+ @group_class.has_many :members, :wrap => "memberUid"
157
+ @group_class.has_many :primary_members,
158
+ :foreign_key => "gidNumber",
159
+ :primary_key => "gidNumber"
160
+ @user_class.set_associated_class(:groups, @group_class)
161
+ @user_class.set_associated_class(:primary_group, @group_class)
162
+ @group_class.set_associated_class(:members, @user_class)
163
+ @group_class.set_associated_class(:primary_members, @user_class)
164
+ end
165
+ end
166
+
167
+ module TemporaryEntry
168
+ @@certificate = nil
169
+ def setup
170
+ super
171
+ @user_index = 0
172
+ @group_index = 0
173
+ end
174
+
175
+ def make_temporary_user(config={})
176
+ @user_index += 1
177
+ uid = config[:uid] || "temp-user#{@user_index}"
178
+ ensure_delete_user(uid) do
179
+ password = config[:password] || "password"
180
+ uid_number = config[:uid_number] || default_uid
181
+ gid_number = config[:gid_number] || default_gid
182
+ home_directory = config[:home_directory] || "/nonexistent"
183
+ _wrap_assertion do
184
+ assert(!@user_class.exists?(uid))
185
+ assert_raise(ActiveLdap::EntryNotFound) do
186
+ @user_class.find(uid).dn
187
+ end
188
+ user = @user_class.new(uid)
189
+ assert(user.new_entry?)
190
+ user.cn = user.uid
191
+ user.sn = user.uid
192
+ user.uid_number = uid_number
193
+ user.gid_number = gid_number
194
+ user.home_directory = home_directory
195
+ user.user_password = ActiveLdap::UserPassword.ssha(password)
196
+ unless config[:simple]
197
+ user.add_class('shadowAccount', 'inetOrgPerson',
198
+ 'organizationalPerson')
199
+ user.user_certificate = certificate
200
+ user.jpeg_photo = jpeg_photo
201
+ end
202
+ user.save
203
+ assert(!user.new_entry?)
204
+ yield(@user_class.find(user.uid), password)
205
+ end
206
+ end
207
+ end
208
+
209
+ def make_temporary_group(config={})
210
+ @group_index += 1
211
+ cn = config[:cn] || "temp-group#{@group_index}"
212
+ ensure_delete_group(cn) do
213
+ gid_number = config[:gid_number] || default_gid
214
+ _wrap_assertion do
215
+ assert(!@group_class.exists?(cn))
216
+ assert_raise(ActiveLdap::EntryNotFound) do
217
+ @group_class.find(cn)
218
+ end
219
+ group = @group_class.new(cn)
220
+ assert(group.new_entry?)
221
+ group.gid_number = gid_number
222
+ assert(group.save)
223
+ assert(!group.new_entry?)
224
+ yield(@group_class.find(group.cn))
225
+ end
226
+ end
227
+ end
228
+
229
+ def ensure_delete_user(uid)
230
+ yield(uid)
231
+ ensure
232
+ @user_class.delete(uid) if @user_class.exists?(uid)
233
+ end
234
+
235
+ def ensure_delete_group(cn)
236
+ yield(cn)
237
+ ensure
238
+ @group_class.delete(cn) if @group_class.exists?(cn)
239
+ end
240
+
241
+ def default_uid
242
+ "10000#{@user_index}"
243
+ end
244
+
245
+ def default_gid
246
+ "10000#{@group_index}"
247
+ end
248
+
249
+ def certificate_path
250
+ File.join(@example_dir, 'example.der')
251
+ end
252
+
253
+ def certificate
254
+ return @@certificate if @@certificate
255
+ if File.exists?(certificate_path)
256
+ @@certificate = File.read(certificate_path)
257
+ return @@certificate
258
+ end
259
+
260
+ rsa = OpenSSL::PKey::RSA.new(512)
261
+ comment = "Generated by Ruby/OpenSSL"
262
+
263
+ cert = OpenSSL::X509::Certificate.new
264
+ cert.version = 3
265
+ cert.serial = 0
266
+ subject = [["OU", "test"],
267
+ ["CN", Socket.gethostname]]
268
+ name = OpenSSL::X509::Name.new(subject)
269
+ cert.subject = name
270
+ cert.issuer = name
271
+ cert.not_before = Time.now
272
+ cert.not_after = Time.now + (365*24*60*60)
273
+ cert.public_key = rsa.public_key
274
+
275
+ ef = OpenSSL::X509::ExtensionFactory.new(nil, cert)
276
+ ef.issuer_certificate = cert
277
+ cert.extensions = [
278
+ ef.create_extension("basicConstraints","CA:FALSE"),
279
+ ef.create_extension("keyUsage", "keyEncipherment"),
280
+ ef.create_extension("subjectKeyIdentifier", "hash"),
281
+ ef.create_extension("extendedKeyUsage", "serverAuth"),
282
+ ef.create_extension("nsComment", comment),
283
+ ]
284
+ aki = ef.create_extension("authorityKeyIdentifier",
285
+ "keyid:always,issuer:always")
286
+ cert.add_extension(aki)
287
+ cert.sign(rsa, OpenSSL::Digest::SHA1.new)
288
+
289
+ @@certificate = cert.to_der
290
+ @@certificate
291
+ end
292
+
293
+ def jpeg_photo_path
294
+ File.join(@example_dir, 'example.jpg')
295
+ end
296
+
297
+ def jpeg_photo
298
+ File.read(jpeg_photo_path)
299
+ end
300
+ end
301
+
302
+ module CommandSupport
303
+ def setup
304
+ super
305
+ @fakeroot = "fakeroot"
306
+ @ruby = File.join(::Config::CONFIG["bindir"],
307
+ ::Config::CONFIG["RUBY_INSTALL_NAME"])
308
+ @top_dir = File.expand_path(File.join(File.dirname(__FILE__), ".."))
309
+ @examples_dir = File.join(@top_dir, "examples")
310
+ @lib_dir = File.join(@top_dir, "lib")
311
+ @ruby_args = [
312
+ "-I", @examples_dir,
313
+ "-I", @lib_dir,
314
+ ]
315
+ end
316
+
317
+ def run_command(*args, &block)
318
+ file = Tempfile.new("al-command-support")
319
+ file.open
320
+ file.puts(ActiveLdap::Base.configurations["test"].to_yaml)
321
+ file.close
322
+ run_ruby(*[@command, "--config", file.path, *args], &block)
323
+ end
324
+
325
+ def run_ruby(*ruby_args, &block)
326
+ args = [@ruby, *@ruby_args]
327
+ args.concat(ruby_args)
328
+ Command.run(*args, &block)
329
+ end
330
+
331
+ def run_ruby_with_fakeroot(*ruby_args, &block)
332
+ args = [@fakeroot, @ruby, *@ruby_args]
333
+ args.concat(ruby_args)
334
+ Command.run(*args, &block)
335
+ end
336
+ end
337
+ end