raptor-io 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (91) hide show
  1. checksums.yaml +15 -0
  2. data/LICENSE +30 -0
  3. data/README.md +51 -0
  4. data/lib/rack/handler/raptor-io.rb +130 -0
  5. data/lib/raptor-io.rb +11 -0
  6. data/lib/raptor-io/error.rb +19 -0
  7. data/lib/raptor-io/protocol.rb +6 -0
  8. data/lib/raptor-io/protocol/error.rb +10 -0
  9. data/lib/raptor-io/protocol/http.rb +34 -0
  10. data/lib/raptor-io/protocol/http/client.rb +685 -0
  11. data/lib/raptor-io/protocol/http/error.rb +16 -0
  12. data/lib/raptor-io/protocol/http/headers.rb +132 -0
  13. data/lib/raptor-io/protocol/http/message.rb +67 -0
  14. data/lib/raptor-io/protocol/http/request.rb +307 -0
  15. data/lib/raptor-io/protocol/http/request/manipulator.rb +117 -0
  16. data/lib/raptor-io/protocol/http/request/manipulators.rb +217 -0
  17. data/lib/raptor-io/protocol/http/request/manipulators/authenticator.rb +110 -0
  18. data/lib/raptor-io/protocol/http/request/manipulators/authenticators/basic.rb +36 -0
  19. data/lib/raptor-io/protocol/http/request/manipulators/authenticators/digest.rb +135 -0
  20. data/lib/raptor-io/protocol/http/request/manipulators/authenticators/negotiate.rb +69 -0
  21. data/lib/raptor-io/protocol/http/request/manipulators/authenticators/ntlm.rb +29 -0
  22. data/lib/raptor-io/protocol/http/request/manipulators/redirect_follower.rb +65 -0
  23. data/lib/raptor-io/protocol/http/response.rb +166 -0
  24. data/lib/raptor-io/protocol/http/server.rb +446 -0
  25. data/lib/raptor-io/ruby.rb +4 -0
  26. data/lib/raptor-io/ruby/hash.rb +24 -0
  27. data/lib/raptor-io/ruby/ipaddr.rb +15 -0
  28. data/lib/raptor-io/ruby/openssl.rb +23 -0
  29. data/lib/raptor-io/ruby/string.rb +27 -0
  30. data/lib/raptor-io/socket.rb +175 -0
  31. data/lib/raptor-io/socket/comm.rb +143 -0
  32. data/lib/raptor-io/socket/comm/local.rb +94 -0
  33. data/lib/raptor-io/socket/comm/sapni.rb +75 -0
  34. data/lib/raptor-io/socket/comm/socks.rb +237 -0
  35. data/lib/raptor-io/socket/comm_chain.rb +30 -0
  36. data/lib/raptor-io/socket/error.rb +45 -0
  37. data/lib/raptor-io/socket/switch_board.rb +183 -0
  38. data/lib/raptor-io/socket/switch_board/route.rb +42 -0
  39. data/lib/raptor-io/socket/tcp.rb +231 -0
  40. data/lib/raptor-io/socket/tcp/ssl.rb +77 -0
  41. data/lib/raptor-io/socket/tcp_server.rb +16 -0
  42. data/lib/raptor-io/socket/tcp_server/ssl.rb +52 -0
  43. data/lib/raptor-io/socket/udp.rb +0 -0
  44. data/lib/raptor-io/version.rb +6 -0
  45. data/lib/tasks/yard.rake +26 -0
  46. data/spec/rack/handler/raptor_spec.rb +140 -0
  47. data/spec/raptor-io/protocol/http/client_spec.rb +671 -0
  48. data/spec/raptor-io/protocol/http/headers_spec.rb +189 -0
  49. data/spec/raptor-io/protocol/http/message_spec.rb +5 -0
  50. data/spec/raptor-io/protocol/http/request/manipulators/authenticator_spec.rb +193 -0
  51. data/spec/raptor-io/protocol/http/request/manipulators/authenticators/basic_spec.rb +32 -0
  52. data/spec/raptor-io/protocol/http/request/manipulators/authenticators/digest_spec.rb +76 -0
  53. data/spec/raptor-io/protocol/http/request/manipulators/authenticators/negotiate_spec.rb +52 -0
  54. data/spec/raptor-io/protocol/http/request/manipulators/authenticators/ntlm_spec.rb +37 -0
  55. data/spec/raptor-io/protocol/http/request/manipulators/redirect_follower_spec.rb +51 -0
  56. data/spec/raptor-io/protocol/http/request/manipulators_spec.rb +202 -0
  57. data/spec/raptor-io/protocol/http/request_spec.rb +965 -0
  58. data/spec/raptor-io/protocol/http/response_spec.rb +236 -0
  59. data/spec/raptor-io/protocol/http/server_spec.rb +345 -0
  60. data/spec/raptor-io/ruby/hash_spec.rb +20 -0
  61. data/spec/raptor-io/ruby/string_spec.rb +20 -0
  62. data/spec/raptor-io/socket/comm/local_spec.rb +50 -0
  63. data/spec/raptor-io/socket/switch_board/route_spec.rb +49 -0
  64. data/spec/raptor-io/socket/switch_board_spec.rb +87 -0
  65. data/spec/raptor-io/socket/tcp/ssl_spec.rb +18 -0
  66. data/spec/raptor-io/socket/tcp_server/ssl_spec.rb +59 -0
  67. data/spec/raptor-io/socket/tcp_server_spec.rb +19 -0
  68. data/spec/raptor-io/socket/tcp_spec.rb +14 -0
  69. data/spec/raptor-io/socket_spec.rb +16 -0
  70. data/spec/raptor-io/version_spec.rb +10 -0
  71. data/spec/spec_helper.rb +56 -0
  72. data/spec/support/fixtures/raptor/protocol/http/request/manipulators/manifoolators/fooer.rb +25 -0
  73. data/spec/support/fixtures/raptor/protocol/http/request/manipulators/niccolo_machiavelli.rb +20 -0
  74. data/spec/support/fixtures/raptor/protocol/http/request/manipulators/options_validator.rb +28 -0
  75. data/spec/support/fixtures/raptor/socket/ssl_server.crt +18 -0
  76. data/spec/support/fixtures/raptor/socket/ssl_server.key +15 -0
  77. data/spec/support/lib/path_helpers.rb +11 -0
  78. data/spec/support/lib/webserver_option_parser.rb +26 -0
  79. data/spec/support/lib/webservers.rb +120 -0
  80. data/spec/support/shared/contexts/with_ssl_server.rb +70 -0
  81. data/spec/support/shared/contexts/with_tcp_server.rb +58 -0
  82. data/spec/support/shared/examples/raptor/comm_examples.rb +26 -0
  83. data/spec/support/shared/examples/raptor/protocols/http/message.rb +106 -0
  84. data/spec/support/shared/examples/raptor/socket_examples.rb +135 -0
  85. data/spec/support/webservers/raptor/protocols/http/client.rb +100 -0
  86. data/spec/support/webservers/raptor/protocols/http/client_close_connection.rb +29 -0
  87. data/spec/support/webservers/raptor/protocols/http/client_https.rb +43 -0
  88. data/spec/support/webservers/raptor/protocols/http/request/manipulators/authenticators/basic.rb +9 -0
  89. data/spec/support/webservers/raptor/protocols/http/request/manipulators/authenticators/digest.rb +22 -0
  90. data/spec/support/webservers/raptor/protocols/http/request/manipulators/redirect_follower.rb +11 -0
  91. metadata +336 -0
@@ -0,0 +1,15 @@
1
+ require 'ipaddr'
2
+
3
+ class IPAddr
4
+
5
+ # @param [String, IPAddr] parse_me Object to parse.
6
+ # @return [IPAddr]
7
+ def self.parse(parse_me)
8
+ if parse_me.kind_of?(IPAddr)
9
+ parse_me
10
+ else
11
+ IPAddr.new(parse_me)
12
+ end
13
+ end
14
+ end
15
+
@@ -0,0 +1,23 @@
1
+ require 'openssl'
2
+
3
+ class OpenSSL::SSL::SSLServer
4
+ # Guard this in case stdlib ever implements it
5
+ unless method_defined?(:accept_nonblock)
6
+ # Non-blocking version of accept, stolen directly from the blocking
7
+ # version, OpenSSL::SSL::SSLServer#accept.
8
+ def accept_nonblock
9
+ sock = @svr.accept_nonblock
10
+
11
+ begin
12
+ ssl = OpenSSL::SSL::SSLSocket.new(sock, @ctx)
13
+ ssl.sync_close = true
14
+ ssl.accept if @start_immediately
15
+ ssl
16
+ rescue OpenSSL::SSL::SSLError => ex
17
+ sock.close
18
+ raise ex
19
+ end
20
+ end
21
+ end
22
+ end
23
+
@@ -0,0 +1,27 @@
1
+ class String
2
+
3
+ # @return [String] `self` with 8-bit unsigned characters.
4
+ def repack
5
+ unpack( 'C*' ).pack( 'C*' )
6
+ end
7
+
8
+ # Forces `self` to UTF-8 and replaces invalid characters.
9
+ def force_utf8!
10
+ force_encoding( 'utf-8' )
11
+ encode!( 'utf-16be', invalid: :replace, undef: :replace ).encode( 'utf-8' )
12
+ end
13
+
14
+ # @return [String] Copy of `self`, {#force_utf8! forced to UTF-8}.
15
+ def force_utf8
16
+ dup.force_utf8!
17
+ end
18
+
19
+ # @return [Bool]
20
+ # `true` if `self` is binary, `false` if regular text.
21
+ def binary?
22
+ encoding == Encoding::ASCII_8BIT ||
23
+ index( "\x00" ) ||
24
+ count( "\x00-\x7F", "^ -~\t\r\n").fdiv( length ) > 0.3
25
+ end
26
+
27
+ end
@@ -0,0 +1,175 @@
1
+ require 'forwardable'
2
+ require 'raptor-io/ruby'
3
+
4
+ # A basic class for specific transports to inherit from. Analogous to
5
+ # stdlib's BasicSocket
6
+ class RaptorIO::Socket
7
+ extend Forwardable
8
+
9
+ require 'raptor-io/socket/error'
10
+ require 'raptor-io/socket/switch_board'
11
+ require 'raptor-io/socket/tcp'
12
+ require 'raptor-io/socket/tcp/ssl'
13
+ require 'raptor-io/socket/tcp_server'
14
+ require 'raptor-io/socket/tcp_server/ssl'
15
+
16
+ # Like IO.select, but smarter
17
+ #
18
+ # OpenSSL does its own buffering which can result in a consumed TCP
19
+ # buffer, leading `IO.select` to think that the SSLSocket has no more
20
+ # data to provide, when that's not the case, effectively making
21
+ # `IO.select` block forever, even though the SSLSocket's buffer has
22
+ # not yet been consumed.
23
+ #
24
+ # We work around this by attempting a non-blocking read of one byte on
25
+ # each of the `read_array`, and putting the byte back with
26
+ # `Socket#ungetc` if it worked, or running it through the the real
27
+ # `IO.select` if it doesn't.
28
+ #
29
+ # @see http://bugs.ruby-lang.org/issues/8875
30
+ # @see http://jira.codehaus.org/browse/JRUBY-6874
31
+ # @param read_array [Array] (see IO.select)
32
+ # @param write_array [Array] (see IO.select)
33
+ # @param error_array [Array] (see IO.select)
34
+ # @param timeout [Fixnum,nil] (see IO.select)
35
+ #
36
+ # @return [Array] An Array containing three arrays of IO objects that
37
+ # are ready for reading, ready for writing, or have pending errors,
38
+ # respectively.
39
+ # @return [nil] If optional `timeout` is given and `timeout` seconds
40
+ # elapse before any data is available
41
+ def self.select(read_array=[], write_array=[], error_array=[], timeout=nil)
42
+ read_array ||= []
43
+ write_array ||= []
44
+ error_array ||= []
45
+
46
+ readers_with_data = []
47
+
48
+ selectable_readers = read_array.dup.delete_if do |reader|
49
+ begin
50
+ # If this socket doesn't have a read_nonblock method, then it's
51
+ # a server of some kind and we have to run it through the real
52
+ # select to see if it can {TCPServer#accept accept}.
53
+ next false unless reader.respond_to? :read_nonblock
54
+
55
+ byte = reader.read_nonblock(1)
56
+ rescue IO::WaitReadable, IO::WaitWritable
57
+ # Then this thing needs to go through the real select to be able
58
+ # to tell if it has data.
59
+ #
60
+ # Note that {IO::WaitWritable} is needed here because OpenSSL
61
+ # sockets can block for writing when calling `read*` because of
62
+ # session renegotiation and the like.
63
+ false
64
+ rescue EOFError
65
+ # Then this thing has an empty read buffer and there's no more
66
+ # on the wire. We mark it as having data so a subsequent
67
+ # read or read_nonblock will raise EOFError appropriately.
68
+ readers_with_data << reader
69
+ true
70
+ else
71
+ # Then this thing has data already in its read buffer and we can
72
+ # skip the real select for it.
73
+ reader.ungetc(byte)
74
+ readers_with_data << reader
75
+ true
76
+ end
77
+ end
78
+
79
+ if readers_with_data.any?
80
+ if selectable_readers.any? || write_array.any? || error_array.any?
81
+ #$stderr.puts(" ----- Selecting readers:")
82
+ #pp selectable_readers
83
+ # Then see if anything has data right now by using a 0 timeout
84
+ r,w,e = IO.select(selectable_readers, write_array, error_array, 0)
85
+
86
+ real = [
87
+ readers_with_data | (r || []),
88
+ w || [],
89
+ e || []
90
+ ]
91
+ else
92
+ # Then there's nothing selectable and we can just return stuff
93
+ # that has buffered data
94
+ real = [ readers_with_data, [], [] ]
95
+ end
96
+ else
97
+ # Then wait the given timeout, regardless of whether the arrays
98
+ # are empty
99
+ real = IO.select(read_array, write_array, error_array, timeout)
100
+ end
101
+
102
+ #$stderr.puts '------ RaptorIO::Socket.select result ------'
103
+ #pp real
104
+ return real
105
+ end
106
+
107
+ class << self
108
+
109
+ # Captures Ruby exceptions and converts them to RaptorIO Errors.
110
+ #
111
+ # @param [Block] block Block to run.
112
+ def translate_errors( &block )
113
+ block.call
114
+ rescue ::Errno::EPIPE, ::Errno::ECONNRESET => e
115
+ raise RaptorIO::Socket::Error::BrokenPipe, e.to_s
116
+ rescue ::Errno::ECONNREFUSED => e
117
+ raise RaptorIO::Socket::Error::ConnectionRefused, e.to_s
118
+ end
119
+
120
+ # Delegates to `::Socket.getaddrinfo`.
121
+ def getaddrinfo( *args )
122
+ begin
123
+ ::Socket.getaddrinfo( *args )
124
+ # OSX raises SocketError.
125
+ rescue ::SocketError, ::Errno::ENOENT => e
126
+ raise RaptorIO::Socket::Error::CouldNotResolve.new( e.to_s )
127
+ end
128
+ end
129
+
130
+ # Delegate to Ruby Socket.
131
+ def method_missing(meth, *args, &block)
132
+ #$stderr.puts("Socket.method_missing(#{meth}, #{args.inspect}")
133
+ if ::Socket.respond_to?(meth)
134
+ translate_errors do
135
+ ::Socket.__send__(meth, *args, &block)
136
+ end
137
+ else
138
+ super
139
+ end
140
+ end
141
+
142
+ def respond_to_missing?(meth, include_private=false)
143
+ ::Socket.respond_to?(meth, include_private)
144
+ end
145
+ end
146
+
147
+ # Options for this socket.
148
+ #
149
+ # @return [Hash<Symbol,Object>]
150
+ attr_accessor :options
151
+
152
+ # @!method to_io
153
+ # Used by Kernel.select
154
+ # @return [IO]
155
+ def_delegator :@socket, :to_io, :to_io
156
+
157
+ # @param socket [IO] An already-connected socket.
158
+ # @param options [Hash] Options (see {#options}).
159
+ def initialize( socket, options = {} )
160
+ @socket = socket
161
+ @options = options
162
+ end
163
+
164
+ # @!method closed?
165
+ def_delegator :@socket, :closed?, :closed?
166
+
167
+ # @!method close
168
+ def_delegator :@socket, :close, :close
169
+
170
+ # Whether this socket is an SSL stream.
171
+ def ssl?
172
+ false
173
+ end
174
+
175
+ end
@@ -0,0 +1,143 @@
1
+ # -*- coding: binary -*-
2
+ require 'raptor-io/socket'
3
+ require 'ipaddr'
4
+
5
+ ###
6
+ #
7
+ # Provides the basic interface that a derived class must implement
8
+ # in order to be a routable socket creator.
9
+ #
10
+ # See {RaptorIO::Socket::Comm::Local} for an implementation using sockets
11
+ # created with standard Ruby Socket classes.
12
+ #
13
+ # Subclasses must implement the following methods:
14
+ #
15
+ # * `resolve`
16
+ # * `create_tcp`
17
+ # * `create_tcp_server`
18
+ # * `create_udp`
19
+ # * `create_udp_server`
20
+ # * `support_ipv6?`
21
+ #
22
+ ###
23
+ class RaptorIO::Socket::Comm
24
+ require 'raptor-io/socket/comm/local'
25
+ require 'raptor-io/socket/comm/socks'
26
+ require 'raptor-io/socket/comm/sapni'
27
+
28
+ # @param uri [URI]
29
+ def self.from_uri(uri, opts = {})
30
+ raise ArgumentError unless uri.kind_of? URI
31
+
32
+ prev_comm = opts[:prev_comm] || RaptorIO::Socket::Comm::Local.new
33
+
34
+ comm = case uri.scheme.downcase
35
+ when "sapni"
36
+ uri.port ||= 3299
37
+ RaptorIO::Socket::Comm::SAPNI.new(
38
+ sap_host: uri.host,
39
+ sap_port: uri.port,
40
+ sap_comm: prev_comm,
41
+ )
42
+ when "socks"
43
+ uri.port ||= 1080
44
+ RaptorIO::Socket::Comm::SOCKS.new(
45
+ socks_host: uri.host,
46
+ socks_port: uri.port,
47
+ socks_comm: prev_comm,
48
+ )
49
+ end
50
+
51
+ comm
52
+ end
53
+
54
+ # Creates a socket on this Comm based on the supplied uniform
55
+ # parameters.
56
+ #
57
+ # @option options :switch_board [SwitchBoard]
58
+ # @option options :port [Fixnum] Optional based on proto
59
+ # @option options :protocol [Symbol]
60
+ # * `:tcp`
61
+ # * `:udp`
62
+ #
63
+ # @return [RaptorIO::Socket]
64
+ def create( options )
65
+ options = options.dup
66
+ options[:peer_host] = IPAddr.parse(options[:peer_host])
67
+
68
+ case options.delete(:protocol)
69
+ when :tcp
70
+ options[:server] ? create_tcp_server(options) : create_tcp(options)
71
+
72
+ when :udp
73
+ options[:server] ? create_udp_server(options) : create_udp(options)
74
+ end
75
+ end
76
+
77
+ # Resolves a hostname to an IP address using this comm.
78
+ #
79
+ # @abstract
80
+ #
81
+ # @param [String] hostname
82
+ def resolve( hostname )
83
+ raise NotImplementedError
84
+ end
85
+
86
+ # Resolves an IP address to a hostname using this comm.
87
+ #
88
+ # @abstract
89
+ #
90
+ # @param ip_address [String]
91
+ def reverse_resolve( ip_address )
92
+ raise NotImplementedError
93
+ end
94
+
95
+ # Connect to a host over TCP.
96
+ #
97
+ # @abstract
98
+ #
99
+ # @option options :peer_host [String,IPAddr]
100
+ # @option options :peer_port [Fixnum]
101
+ # @option options :local_host [String,IPAddr]
102
+ # @option options :local_port [Fixnum]
103
+ # @return [RaptorIO::Socket::TCP]
104
+ def create_tcp(options)
105
+ raise NotImplementedError
106
+ end
107
+
108
+ # Create a UDP socket bound to the given :peer_host
109
+ #
110
+ # @abstract
111
+ #
112
+ # @option options :peer_host [String,IPAddr]
113
+ # @option options :peer_port [Fixnum]
114
+ # @option options :local_host [String,IPAddr]
115
+ # @option options :local_port [Fixnum]
116
+ def create_udp(options)
117
+ raise NotImplementedError
118
+ end
119
+
120
+ # Create a TCP server listening on :local_port
121
+ #
122
+ # @abstract
123
+ #
124
+ # @option options :local_host [String,IPAddr]
125
+ # @option options :local_port [Fixnum]
126
+ # @option options :ssl_context [OpenSSL::SSL::Context]
127
+ def create_tcp_server(options)
128
+ raise NotImplementedError
129
+ end
130
+
131
+ # Create a UDP server listening on :local_port
132
+ #
133
+ # @abstract
134
+ #
135
+ # @option options :local_host [String,IPAddr]
136
+ # @option options :local_port [Fixnum]
137
+ def create_udp_server(options)
138
+ raise NotImplementedError
139
+ end
140
+
141
+
142
+ end
143
+
@@ -0,0 +1,94 @@
1
+ # -*- coding: binary -*-
2
+ require 'timeout'
3
+ require 'socket'
4
+ require 'resolv'
5
+
6
+ # Local communication using Ruby `::Socket`s
7
+ class RaptorIO::Socket::Comm::Local < RaptorIO::Socket::Comm
8
+
9
+ # Determine whether we support IPv6
10
+ #
11
+ # We attempt to discover this by creating an unbound UDP socket with
12
+ # the AF_INET6 address family
13
+ def support_ipv6?
14
+ return @supports_ipv6 unless @supports_ipv6.nil?
15
+
16
+ @supports_ipv6 = false
17
+
18
+ if ::Socket.const_defined?('AF_INET6')
19
+ begin
20
+ ::Socket.new(::Socket::AF_INET6, ::Socket::SOCK_DGRAM, ::Socket::IPPROTO_UDP).close
21
+ @supports_ipv6 = true
22
+ rescue
23
+ end
24
+ end
25
+
26
+ @supports_ipv6
27
+ end
28
+
29
+ # Resolves a hostname to an IP address using this comm.
30
+ #
31
+ # @param hostname [String]
32
+ def resolve( hostname )
33
+ ::Resolv.getaddress hostname
34
+ end
35
+
36
+ # Resolves an IP address to a hostname using this comm.
37
+ #
38
+ # @param [String] ip_address
39
+ def reverse_resolve( ip_address )
40
+ ::Resolv.getname ip_address
41
+ end
42
+
43
+ # Connect to `:peer_host`
44
+ #
45
+ # @option (see Comm#create_tcp)
46
+ # @return [Socket::TCP]
47
+ # @raise [RaptorIO::Socket::Error::ConnectTimeout]
48
+ def create_tcp( options )
49
+ phost = IPAddr.parse( options[:peer_host] )
50
+
51
+ # Passing an explicit ::Socket::IPPROTO_TCP is broken on jruby
52
+ # See https://github.com/jruby/jruby/issues/785
53
+ socket = ::Socket.new(phost.family, ::Socket::SOCK_STREAM, 0)
54
+ socket.do_not_reverse_lookup = true
55
+
56
+ if options[:local_port] || options[:local_host]
57
+ socket.bind(::Socket.pack_sockaddr_in(options[:local_port], options[:local_host]))
58
+ end
59
+
60
+ begin
61
+ socket.connect_nonblock(::Socket.pack_sockaddr_in(options[:peer_port], phost.to_s))
62
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET
63
+ raise RaptorIO::Socket::Error::ConnectionRefused
64
+ rescue Errno::EINPROGRESS
65
+ # This should almost always be raised with a call to
66
+ # connect_nonblock. When the socket finishes connecting it
67
+ # becomes available for writing.
68
+ res = select(nil, [socket], nil, options[:connect_timeout] || 2)
69
+ if res.nil?
70
+ raise RaptorIO::Socket::Error::ConnectionTimeout
71
+ end
72
+ end
73
+
74
+ if options[:ssl_context]
75
+ RaptorIO::Socket::TCP::SSL.new(socket, options)
76
+ else
77
+ RaptorIO::Socket::TCP.new(socket, options)
78
+ end
79
+ end
80
+
81
+ # Listen locally on `:local_port`
82
+ #
83
+ # @option (see Comm#create_tcp_server)
84
+ def create_tcp_server( options )
85
+ socket = TCPServer.new( options[:local_host], options[:local_port] )
86
+
87
+ if (options[:context] = options.delete(:ssl_context))
88
+ RaptorIO::Socket::TCPServer::SSL.new( socket, options )
89
+ else
90
+ RaptorIO::Socket::TCPServer.new( socket, options )
91
+ end
92
+ end
93
+
94
+ end