rom-ldap 0.2.2

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 (104) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +251 -0
  3. data/CONTRIBUTING.md +18 -0
  4. data/README.md +172 -0
  5. data/TODO.md +33 -0
  6. data/config/responses.yml +328 -0
  7. data/lib/dry/monitor/ldap/colorizers/default.rb +17 -0
  8. data/lib/dry/monitor/ldap/colorizers/rouge.rb +31 -0
  9. data/lib/dry/monitor/ldap/logger.rb +58 -0
  10. data/lib/rom-ldap.rb +1 -0
  11. data/lib/rom/ldap.rb +22 -0
  12. data/lib/rom/ldap/alias.rb +30 -0
  13. data/lib/rom/ldap/associations.rb +6 -0
  14. data/lib/rom/ldap/associations/core.rb +23 -0
  15. data/lib/rom/ldap/associations/many_to_many.rb +18 -0
  16. data/lib/rom/ldap/associations/many_to_one.rb +22 -0
  17. data/lib/rom/ldap/associations/one_to_many.rb +32 -0
  18. data/lib/rom/ldap/associations/one_to_one.rb +19 -0
  19. data/lib/rom/ldap/associations/self_ref.rb +35 -0
  20. data/lib/rom/ldap/attribute.rb +327 -0
  21. data/lib/rom/ldap/client.rb +185 -0
  22. data/lib/rom/ldap/client/authentication.rb +118 -0
  23. data/lib/rom/ldap/client/operations.rb +233 -0
  24. data/lib/rom/ldap/commands.rb +6 -0
  25. data/lib/rom/ldap/commands/create.rb +41 -0
  26. data/lib/rom/ldap/commands/delete.rb +17 -0
  27. data/lib/rom/ldap/commands/update.rb +35 -0
  28. data/lib/rom/ldap/constants.rb +193 -0
  29. data/lib/rom/ldap/dataset.rb +286 -0
  30. data/lib/rom/ldap/dataset/conversion.rb +62 -0
  31. data/lib/rom/ldap/dataset/dsl.rb +299 -0
  32. data/lib/rom/ldap/dataset/persistence.rb +44 -0
  33. data/lib/rom/ldap/directory.rb +126 -0
  34. data/lib/rom/ldap/directory/capabilities.rb +71 -0
  35. data/lib/rom/ldap/directory/entry.rb +200 -0
  36. data/lib/rom/ldap/directory/env.rb +155 -0
  37. data/lib/rom/ldap/directory/operations.rb +282 -0
  38. data/lib/rom/ldap/directory/password.rb +122 -0
  39. data/lib/rom/ldap/directory/root.rb +187 -0
  40. data/lib/rom/ldap/directory/tokenization.rb +66 -0
  41. data/lib/rom/ldap/directory/transactions.rb +31 -0
  42. data/lib/rom/ldap/directory/vendors/active_directory.rb +129 -0
  43. data/lib/rom/ldap/directory/vendors/apache_ds.rb +27 -0
  44. data/lib/rom/ldap/directory/vendors/e_directory.rb +16 -0
  45. data/lib/rom/ldap/directory/vendors/open_directory.rb +12 -0
  46. data/lib/rom/ldap/directory/vendors/open_dj.rb +25 -0
  47. data/lib/rom/ldap/directory/vendors/open_ldap.rb +35 -0
  48. data/lib/rom/ldap/directory/vendors/three_eight_nine.rb +16 -0
  49. data/lib/rom/ldap/directory/vendors/unknown.rb +22 -0
  50. data/lib/rom/ldap/dsl.rb +76 -0
  51. data/lib/rom/ldap/errors.rb +47 -0
  52. data/lib/rom/ldap/expression.rb +77 -0
  53. data/lib/rom/ldap/expression_encoder.rb +174 -0
  54. data/lib/rom/ldap/extensions.rb +50 -0
  55. data/lib/rom/ldap/extensions/active_support_notifications.rb +26 -0
  56. data/lib/rom/ldap/extensions/compatibility.rb +11 -0
  57. data/lib/rom/ldap/extensions/dsml.rb +165 -0
  58. data/lib/rom/ldap/extensions/msgpack.rb +23 -0
  59. data/lib/rom/ldap/extensions/optimised_json.rb +25 -0
  60. data/lib/rom/ldap/extensions/rails_log_subscriber.rb +38 -0
  61. data/lib/rom/ldap/formatter.rb +26 -0
  62. data/lib/rom/ldap/functions.rb +207 -0
  63. data/lib/rom/ldap/gateway.rb +145 -0
  64. data/lib/rom/ldap/ldif.rb +74 -0
  65. data/lib/rom/ldap/ldif/exporter.rb +77 -0
  66. data/lib/rom/ldap/ldif/importer.rb +95 -0
  67. data/lib/rom/ldap/mapper_compiler.rb +19 -0
  68. data/lib/rom/ldap/matchers.rb +69 -0
  69. data/lib/rom/ldap/message_queue.rb +7 -0
  70. data/lib/rom/ldap/oid.rb +101 -0
  71. data/lib/rom/ldap/parsers/abstract_syntax.rb +91 -0
  72. data/lib/rom/ldap/parsers/attribute.rb +290 -0
  73. data/lib/rom/ldap/parsers/filter_syntax.rb +133 -0
  74. data/lib/rom/ldap/pdu.rb +285 -0
  75. data/lib/rom/ldap/plugin/pagination.rb +145 -0
  76. data/lib/rom/ldap/plugins.rb +7 -0
  77. data/lib/rom/ldap/projection_dsl.rb +38 -0
  78. data/lib/rom/ldap/relation.rb +135 -0
  79. data/lib/rom/ldap/relation/exporting.rb +72 -0
  80. data/lib/rom/ldap/relation/reading.rb +461 -0
  81. data/lib/rom/ldap/relation/writing.rb +64 -0
  82. data/lib/rom/ldap/responses.rb +17 -0
  83. data/lib/rom/ldap/restriction_dsl.rb +45 -0
  84. data/lib/rom/ldap/schema.rb +123 -0
  85. data/lib/rom/ldap/schema/attributes_inferrer.rb +59 -0
  86. data/lib/rom/ldap/schema/dsl.rb +13 -0
  87. data/lib/rom/ldap/schema/inferrer.rb +50 -0
  88. data/lib/rom/ldap/schema/type_builder.rb +133 -0
  89. data/lib/rom/ldap/scope.rb +19 -0
  90. data/lib/rom/ldap/search_request.rb +249 -0
  91. data/lib/rom/ldap/socket.rb +210 -0
  92. data/lib/rom/ldap/tasks/ldap.rake +103 -0
  93. data/lib/rom/ldap/tasks/ldif.rake +80 -0
  94. data/lib/rom/ldap/transaction.rb +29 -0
  95. data/lib/rom/ldap/type_map.rb +88 -0
  96. data/lib/rom/ldap/types.rb +158 -0
  97. data/lib/rom/ldap/version.rb +17 -0
  98. data/lib/rom/plugins/relation/ldap/active_directory.rb +182 -0
  99. data/lib/rom/plugins/relation/ldap/auto_restrictions.rb +69 -0
  100. data/lib/rom/plugins/relation/ldap/e_directory.rb +27 -0
  101. data/lib/rom/plugins/relation/ldap/instrumentation.rb +35 -0
  102. data/lib/rouge/lexers/ldap.rb +72 -0
  103. data/lib/rouge/themes/ldap.rb +49 -0
  104. metadata +231 -0
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/sha1'
4
+ require 'digest/sha2'
5
+ require 'digest/md5'
6
+ require 'base64'
7
+ require 'securerandom'
8
+
9
+ # sha2-512 512bit or 64bytes
10
+ # sha 160bits or 20bytes
11
+ #
12
+ module ROM
13
+ module LDAP
14
+ class Directory
15
+
16
+ # @abstract
17
+ # Encode and validate passwords using md5, sha or ssha.
18
+ #
19
+ # @api public
20
+ class Password
21
+
22
+ # Generate an ecrypted password.
23
+ #
24
+ # @example
25
+ # Password.generate(:ssha, 'secret magic word')
26
+ #
27
+ # @param type [Symbol] Encryption type. [:md5, :sha, :ssha].
28
+ # @param password [String] Plain text password to be encrypted.
29
+ #
30
+ # @return [String]
31
+ #
32
+ # @raise [PasswordError]
33
+ #
34
+ # @api public
35
+ def self.generate(type, password, salt = secure_salt)
36
+ raise PasswordError, 'No password supplied' if password.nil?
37
+
38
+ case type
39
+ when :md5 then _encode(type, md5(password))
40
+ when :sha then _encode(type, sha(password))
41
+ when :ssha then _encode(type, ssha(password, salt))
42
+ when :ssha512 then _encode(type, ssha512(password, salt))
43
+ else
44
+ raise PasswordError, "Unsupported encryption type (#{type})"
45
+ end
46
+ end
47
+
48
+ def self.check_ssha512(password, encrypted)
49
+ decoded = Base64.decode64(encrypted.gsub(/^{SSHA512}/, EMPTY_STRING))
50
+ # hash = decoded[0..64]
51
+ salt = decoded[64..-1]
52
+ _encode(:ssha512, ssha512(password, salt)) == encrypted
53
+ end
54
+
55
+ # Validate plain password against encrypted SSHA password.
56
+ #
57
+ # @return [Boolean]
58
+ #
59
+ # @api public
60
+ def self.check_ssha(password, encrypted)
61
+ decoded = Base64.decode64(encrypted.gsub(/^{SSHA}/, EMPTY_STRING))
62
+ # hash = decoded[0..20]
63
+ salt = decoded[20..-1]
64
+ _encode(:ssha, ssha(password, salt)) == encrypted
65
+ end
66
+
67
+ private_class_method
68
+
69
+ # @return [String] Prepend type to encrypted string.
70
+ #
71
+ # @api private
72
+ def self._encode(type, encrypted)
73
+ "{#{type.upcase}}" + Base64.strict_encode64(encrypted).chomp
74
+ end
75
+
76
+ # Generate salt.
77
+ #
78
+ # @api private
79
+ def self.secure_salt
80
+ SecureRandom.random_bytes(16)
81
+ end
82
+
83
+ # @param str [String]
84
+ #
85
+ # @return [String] MD5 digest.
86
+ #
87
+ # @api private
88
+ def self.md5(str)
89
+ Digest::MD5.digest(str)
90
+ end
91
+
92
+ # @param str [String]
93
+ # @param salt [String]
94
+ #
95
+ # @return [String] SHA1 digest with salt.
96
+ #
97
+ # @api private
98
+ def self.ssha(str, salt)
99
+ Digest::SHA1.digest(str + salt) + salt
100
+ end
101
+
102
+ # @param str [String]
103
+ #
104
+ # @return [String] SHA1 digest without salt.
105
+ #
106
+ # @api private
107
+ def self.sha(str)
108
+ Digest::SHA1.digest(str)
109
+ end
110
+
111
+ # "{SSHA512}A1lCCGYzUEJ5/qQCrFUAztLVaTaWv959RnpzaOsWB9Ij4CBCeNh6i4XrZzrvwUMM/AWbEb8Gjc7FWOBSPnkRuHsexjzeQImm"
112
+ # initial
113
+ #
114
+ def self.ssha512(str, salt)
115
+ Digest::SHA512.digest(str + salt) + salt
116
+ end
117
+
118
+ end
119
+
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ROM
4
+ module LDAP
5
+ class Directory
6
+
7
+ module Root
8
+ # Identify the LDAP server vendor, type determines vendor extension to load.
9
+ #
10
+ # @see https://ldapwiki.com/wiki/Determine%20LDAP%20Server%20Vendor
11
+ #
12
+ # @return [Symbol]
13
+ #
14
+ # @api public
15
+ def type
16
+ case root.first('vendorName')
17
+ when /389/ then :three_eight_nine
18
+ when /Apache/ then :apache_ds
19
+ when /Apple/ then :open_directory
20
+ when /ForgeRock/ then :open_dj
21
+ when /IBM/ then :ibm
22
+ when /Netscape/ then :netscape
23
+ when /Novell/ then :e_directory
24
+ when /Oracle/ then :open_ds
25
+ when /Sun/ then :sun_microsystems
26
+ when nil
27
+ return :active_directory if ad?
28
+ return :open_ldap if od?
29
+ else
30
+ :unknown
31
+ end
32
+ end
33
+
34
+ # @return [String]
35
+ #
36
+ # @api public
37
+ def vendor_name
38
+ root.first('vendorName')
39
+ end
40
+
41
+ # @return [String]
42
+ #
43
+ # @api public
44
+ def vendor_version
45
+ root.first('vendorVersion')
46
+ end
47
+
48
+ # @example
49
+ # [ 'Apple', '510.30' ]
50
+ # [ 'Apache Software Foundation', '2.0.0-M24' ]
51
+ #
52
+ # @return [Array<String>]
53
+ #
54
+ # @api public
55
+ def vendor
56
+ [vendor_name, vendor_version]
57
+ end
58
+
59
+ # Distinguished name of subschema
60
+ #
61
+ # @return [String]
62
+ #
63
+ # @api public
64
+ def sub_schema_entry
65
+ root.first('subschemaSubentry')
66
+ end
67
+
68
+ # @return [Array<String>] Object classes known by directory
69
+ #
70
+ # @api public
71
+ def schema_object_classes
72
+ sub_schema['objectClasses'].sort
73
+ end
74
+
75
+ # Query directory for all known attribute types
76
+ #
77
+ # @return [Array<String>] Attribute types known by directory
78
+ #
79
+ # @api public
80
+ def schema_attribute_types
81
+ sub_schema['attributeTypes'].sort
82
+ end
83
+
84
+ # @return [Array<String>]
85
+ #
86
+ # @api public
87
+ def supported_extensions
88
+ root['supportedExtension'].sort
89
+ end
90
+
91
+ # @return [Array<String>]
92
+ #
93
+ # @api public
94
+ def supported_controls
95
+ root['supportedControl'].sort
96
+ end
97
+
98
+ # @return [Array<String>]
99
+ #
100
+ # @api public
101
+ def supported_mechanisms
102
+ root['supportedSASLMechanisms'].sort
103
+ end
104
+
105
+ # @return [Array<String>]
106
+ #
107
+ # @api public
108
+ def supported_features
109
+ root['supportedFeatures'].sort
110
+ end
111
+
112
+ # @return [Array<Integer>]
113
+ #
114
+ # @api public
115
+ def supported_versions
116
+ root['supportedLDAPVersion'].sort.map(&:to_i)
117
+ end
118
+
119
+ # @return [String]
120
+ #
121
+ # @api public
122
+ def contexts
123
+ root['namingContexts'].sort
124
+ end
125
+
126
+ private
127
+
128
+ # Representation of directory RootDSE
129
+ #
130
+ # @see https://ldapwiki.com/wiki/Retrieving%20RootDSE
131
+ #
132
+ # @return [Directory::Entry]
133
+ #
134
+ # @raise [ResponseError]
135
+ #
136
+ # @api private
137
+ def root
138
+ @root ||= query(
139
+ base: EMPTY_STRING,
140
+ scope: SCOPE_BASE,
141
+ attributes: ALL_ATTRS
142
+ ).first
143
+
144
+ @root || raise(ResponseError, 'Directory root failed to load')
145
+ end
146
+
147
+ # Representation of directory SubSchema
148
+ #
149
+ # @return [Directory::Entry]
150
+ #
151
+ # @raise [ResponseError]
152
+ #
153
+ # @api private
154
+ def sub_schema
155
+ @sub_schema ||= query(
156
+ base: sub_schema_entry,
157
+ scope: SCOPE_BASE,
158
+ attributes: %w[objectClasses attributeTypes],
159
+ filter: '(objectClass=subschema)',
160
+ max_results: 1
161
+ ).first
162
+
163
+ @sub_schema || raise(ResponseError, 'Directory schema failed to load')
164
+ end
165
+
166
+ # Check if vendor identifies as ActiveDirectory
167
+ #
168
+ # @return [Boolean]
169
+ #
170
+ # @api private
171
+ def ad?
172
+ !root['forestFunctionality'].nil?
173
+ end
174
+
175
+ # Check if vendor identifies as OpenLDAP
176
+ #
177
+ # @return [Boolean]
178
+ #
179
+ # @api private
180
+ def od?
181
+ root['objectClass']&.include?('OpenLDAProotDSE')
182
+ end
183
+ end
184
+
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rom/ldap/parsers/abstract_syntax'
4
+ require 'rom/ldap/parsers/filter_syntax'
5
+ require 'rom/ldap/parsers/attribute'
6
+
7
+ module ROM
8
+ module LDAP
9
+ class Directory
10
+
11
+ # Parsing Formats
12
+ #
13
+ # @api private
14
+ module Tokenization
15
+ # Allows adapters that subclass Directory to use custom parsers.
16
+ # Extends the class with filter abstraction behavior.
17
+ #
18
+ # @api private
19
+ def self.included(klass)
20
+ klass.class_eval do
21
+ extend Dry::Core::ClassAttributes
22
+
23
+ defines :attribute_class
24
+ attribute_class Parsers::Attribute
25
+
26
+ defines :filter_class
27
+ filter_class Parsers::FilterSyntax
28
+
29
+ defines :ast_class
30
+ ast_class Parsers::AbstractSyntax
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Convert abstract criteria or LDAP filter into an expression object.
37
+ # Check for parsed attributes to prevent recursion.
38
+ #
39
+ # @param input [Array, String] RFC2254 or AST
40
+ #
41
+ def to_expression(input)
42
+ attrs = !@attribute_types.nil? ? attribute_types : EMPTY_ARRAY
43
+
44
+ # Filter > AST
45
+ unless input.is_a?(Array)
46
+ input = self.class.filter_class.new(input, attrs).call
47
+ end
48
+
49
+ # AST > Expression
50
+ self.class.ast_class.new(input, attrs).call
51
+ end
52
+
53
+ # Create parsed attribute from definiton.
54
+ #
55
+ # @param attr_def [String]
56
+ #
57
+ # @return [Hash]
58
+ #
59
+ def to_attribute(attr_def)
60
+ self.class.attribute_class.new(attr_def).call
61
+ end
62
+ end
63
+
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ROM
4
+ module LDAP
5
+ class Directory
6
+
7
+ # https://ldapwiki.com/wiki/Lightweight%20Directory%20Access%20Protocol%20%28LDAP%29%20Transactions
8
+ # https://tools.ietf.org/html/rfc5805
9
+ # https://ldapwiki.com/wiki/EDirectory%20LDAP%20Transaction
10
+ #
11
+ module Transactions
12
+ # @example
13
+ # directory.transaction(opts) { yield(self) }
14
+ #
15
+ # @todo Transactions WIP
16
+ #
17
+ # @api public
18
+ def transaction(_opts)
19
+ # binding.pry
20
+
21
+ # OID[:transaction_start_request]
22
+ # OID[:transaction_spec_request]
23
+ # OID[:transaction_end_request]
24
+
25
+ yield()
26
+ end
27
+ end
28
+
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ROM
4
+ module LDAP
5
+ #
6
+ # Microsoft Active Directory Extension
7
+ #
8
+ # @api private
9
+ module ActiveDirectory
10
+ #
11
+ # @note
12
+ # Use the AD Forest configuration container as a search base.
13
+ #
14
+ # @see https://msdn.microsoft.com/en-us/library/ms684291(v=vs.85).aspx
15
+ #
16
+ # RootDSE domainFunctionality
17
+ # RootDSE domainControllerFunctionality
18
+ # RootDSE forestFunctionality
19
+ #
20
+ VERSION_NAMES = {
21
+ 0 => 'Windows Server 2000 (5.0)',
22
+ 1 => 'Windows Server 2003 (5.2)',
23
+ 2 => 'Windows Server 2003 R2 (5.2)',
24
+ 3 => 'Windows Server 2008 (6.0)',
25
+ 4 => 'Windows Server 2008 R2 (6.1)',
26
+ 5 => 'Windows Server 2012 (6.2)',
27
+ 6 => 'Windows Server 2012 R2 (6.3)',
28
+ 7 => 'Windows Server 2016 (10.0)',
29
+ 8 => 'Windows Server Latest Version (?)'
30
+ }.freeze
31
+
32
+ #
33
+ # RootDSE supportedLDAPPolicies
34
+ #
35
+ POLICIES = %w[
36
+ InitRecvTimeout
37
+ MaxBatchReturnMessages
38
+ MaxConnections
39
+ MaxConnIdleTime
40
+ MaxDatagramRecv
41
+ MaxNotificationPerConn
42
+ MaxPageSize
43
+ MaxPercentDirSyncRequests
44
+ MaxPoolThreads
45
+ MaxQueryDuration
46
+ MaxReceiveBuffer
47
+ MaxResultSetSize
48
+ MaxResultSetsPerConn
49
+ MaxTempTableSize
50
+ MaxValRange
51
+ MaxValRangeTransitive
52
+ MinResultSets
53
+ SystemMemoryLimitPercent
54
+ ThreadMemoryLimit
55
+ ].freeze
56
+
57
+ # @return [String]
58
+ #
59
+ def vendor_name
60
+ 'Microsoft'
61
+ end
62
+
63
+ # @return [String]
64
+ #
65
+ def vendor_version
66
+ VERSION_NAMES[domain_functionality]
67
+ end
68
+
69
+ # @return [Integer]
70
+ #
71
+ def controller_functionality
72
+ root.first('domainControllerFunctionality').to_i
73
+ end
74
+
75
+ # @return [Integer]
76
+ #
77
+ def forest_functionality
78
+ root.first('forestFunctionality').to_i
79
+ end
80
+
81
+ # @return [Integer]
82
+ #
83
+ def domain_functionality
84
+ root.first('domainFunctionality').to_i
85
+ end
86
+
87
+ # LDAP server internal clock
88
+ #
89
+ # @return [Time]
90
+ #
91
+ def directory_time
92
+ Functions[:to_time][root.first('currentTime')]
93
+ end
94
+
95
+ # @return [Array<String>]
96
+ #
97
+ def supported_capabilities
98
+ root['supportedCapabilities'].sort
99
+ end
100
+ end
101
+
102
+ # OID = OID.dup.merge!(
103
+ # extended_dn: '1.2.840.113556.1.4.529', # Extended DN control (Stateless)
104
+ # get_stats: '1.2.840.113556.1.4.970', # Get stats control (Stateless)
105
+ # verify_name: '1.2.840.113556.1.4.1338', # Verify name control (Stateless)
106
+ # domain_scope: '1.2.840.113556.1.4.1339', # LDAP_SERVER_DOMAIN_SCOPE_OID
107
+ # unknown: '1.2.840.113556.1.4.1340',
108
+ # # unknown: '1.2.840.113556.1.4.1341',
109
+ # # unknown: '1.2.840.113556.1.4.1413',
110
+ # # unknown: '1.2.840.113556.1.4.1504',
111
+ # # unknown: '1.2.840.113556.1.4.1852',
112
+ # # unknown: '1.2.840.113556.1.4.1907',
113
+ # # unknown: '1.2.840.113556.1.4.1948',
114
+ # # unknown: '1.2.840.113556.1.4.1974',
115
+ # # unknown: '1.2.840.113556.1.4.2026',
116
+ # # unknown: '1.2.840.113556.1.4.2064',
117
+ # # unknown: '1.2.840.113556.1.4.2065',
118
+ # # unknown: '1.2.840.113556.1.4.2066',
119
+ # # unknown: '1.2.840.113556.1.4.2090',
120
+ # # unknown: '1.2.840.113556.1.4.2204',
121
+ # # unknown: '1.2.840.113556.1.4.2205',
122
+ # # unknown: '1.2.840.113556.1.4.2206',
123
+ # # unknown: '1.2.840.113556.1.4.2211',
124
+ # # unknown: '1.2.840.113556.1.4.2239',
125
+ # # unknown: '1.2.840.113556.1.4.2255',
126
+ # # unknown: '1.2.840.113556.1.4.2256'
127
+ # ).freeze
128
+ end
129
+ end