cool.io 1.9.3 → 1.9.5
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.
- checksums.yaml +4 -4
- data/.github/workflows/test.yaml +1 -0
- data/.github/workflows/valgrind.yaml +44 -0
- data/Rakefile +6 -19
- data/cool.io.gemspec +1 -3
- data/ext/cool.io/buffer.c +50 -39
- data/ext/cool.io/cool.io.h +6 -0
- data/ext/cool.io/extconf.rb +0 -1
- data/ext/cool.io/loop.c +15 -15
- data/ext/cool.io/stat_watcher.c +15 -2
- data/ext/cool.io/watcher.c +13 -1
- data/ext/cool.io/watcher.h +7 -12
- data/ext/libev/ev.c +7 -1
- data/ext/libev/ev_select.c +10 -0
- data/gems.rb +4 -0
- data/lib/cool.io/dns_resolver.rb +52 -8
- data/lib/cool.io/version.rb +2 -2
- data/libev_win_select.diff +25 -9
- data/spec/async_watcher_spec.rb +3 -1
- data/spec/detach_race_condition_spec.rb +87 -20
- data/spec/dns_spec.rb +186 -0
- data/spec/iobuffer_spec.rb +31 -0
- data/spec/stat_watcher_spec.rb +31 -1
- data/spec/tcp_server_spec.rb +28 -4
- metadata +3 -2
data/lib/cool.io/dns_resolver.rb
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
#++
|
|
19
19
|
|
|
20
20
|
require 'resolv'
|
|
21
|
+
require 'securerandom'
|
|
22
|
+
require 'socket'
|
|
21
23
|
|
|
22
24
|
module Coolio
|
|
23
25
|
# A non-blocking DNS resolver. It provides interfaces for querying both
|
|
@@ -67,14 +69,22 @@ module Coolio
|
|
|
67
69
|
# list of nameservers to query. By default the resolver will
|
|
68
70
|
# use nameservers listed in /etc/resolv.conf
|
|
69
71
|
def initialize(hostname, *nameservers)
|
|
72
|
+
nameservers = reject_ipv6_nameservers(nameservers)
|
|
70
73
|
if nameservers.empty?
|
|
71
|
-
nameservers = Resolv::DNS::Config.default_config_hash[:nameserver]
|
|
74
|
+
nameservers = reject_ipv6_nameservers(Resolv::DNS::Config.default_config_hash[:nameserver])
|
|
72
75
|
raise RuntimeError, "no nameservers found" if nameservers.empty? # TODO just call resolve_failed, not raise [also handle Errno::ENOENT)]
|
|
73
76
|
end
|
|
74
77
|
|
|
75
78
|
@nameservers = nameservers.dup
|
|
76
79
|
@question = request_question hostname
|
|
77
80
|
|
|
81
|
+
# A guessable ID would let an off-path attacker forge a response
|
|
82
|
+
@request_id = SecureRandom.random_number(1 << 16)
|
|
83
|
+
|
|
84
|
+
# Numeric addresses this query was sent to, and the lookups behind them
|
|
85
|
+
@queried_addresses = []
|
|
86
|
+
@numeric_addresses = {}
|
|
87
|
+
|
|
78
88
|
@socket = UDPSocket.new
|
|
79
89
|
@timer = Timeout.new(self)
|
|
80
90
|
|
|
@@ -113,27 +123,44 @@ module Coolio
|
|
|
113
123
|
|
|
114
124
|
# Send a request to the DNS server
|
|
115
125
|
def send_request
|
|
116
|
-
|
|
117
|
-
|
|
126
|
+
@nameservers.rotate!
|
|
127
|
+
|
|
128
|
+
# Send to the numeric address, so we know where a response must come from
|
|
129
|
+
address = numeric_address(@nameservers.first)
|
|
130
|
+
@queried_addresses << address unless @queried_addresses.include?(address)
|
|
131
|
+
|
|
118
132
|
begin
|
|
119
|
-
@socket.send request_message, 0,
|
|
133
|
+
@socket.send request_message, 0, address, DNS_PORT
|
|
120
134
|
rescue Errno::EHOSTUNREACH # TODO figure out why it has to be wrapper here, when the other wrapper should be wrapping this one!
|
|
121
135
|
end
|
|
122
136
|
end
|
|
123
137
|
|
|
124
138
|
# Called by the subclass when the DNS response is available
|
|
125
139
|
def on_readable
|
|
126
|
-
datagram = nil
|
|
140
|
+
datagram = sender = nil
|
|
127
141
|
begin
|
|
128
|
-
datagram = @socket.recvfrom_nonblock(DATAGRAM_SIZE)
|
|
142
|
+
datagram, sender = @socket.recvfrom_nonblock(DATAGRAM_SIZE)
|
|
129
143
|
rescue Errno::ECONNREFUSED
|
|
130
144
|
end
|
|
131
145
|
|
|
146
|
+
# Ignore anything we didn't ask for, rather than resolving or failing on it.
|
|
147
|
+
# The query stays outstanding, so the retry timer still bounds us.
|
|
148
|
+
return if datagram and not solicited_response?(datagram, sender)
|
|
149
|
+
|
|
132
150
|
address = response_address datagram rescue nil
|
|
133
151
|
address ? on_success(address) : on_failure
|
|
134
152
|
detach
|
|
135
153
|
end
|
|
136
154
|
|
|
155
|
+
# Is this a reply to our query, from an address we sent it to?
|
|
156
|
+
# Retries rotate through @nameservers, so any address already queried counts.
|
|
157
|
+
def solicited_response?(datagram, sender)
|
|
158
|
+
return false unless datagram.size >= 12
|
|
159
|
+
return false unless sender and sender[1] == DNS_PORT and @queried_addresses.include?(sender[3])
|
|
160
|
+
|
|
161
|
+
datagram[0..1].unpack('n').first.to_i == @request_id
|
|
162
|
+
end
|
|
163
|
+
|
|
137
164
|
def request_question(hostname)
|
|
138
165
|
raise ArgumentError, "hostname cannot be nil" if hostname.nil?
|
|
139
166
|
|
|
@@ -151,7 +178,7 @@ module Coolio
|
|
|
151
178
|
|
|
152
179
|
def request_message
|
|
153
180
|
# Standard query header
|
|
154
|
-
message = [
|
|
181
|
+
message = [@request_id, 1, 0].pack('nCC')
|
|
155
182
|
|
|
156
183
|
# One entry
|
|
157
184
|
qdcount = 1
|
|
@@ -166,7 +193,7 @@ module Coolio
|
|
|
166
193
|
def response_address(message)
|
|
167
194
|
# Confirm the ID field
|
|
168
195
|
id = message[0..1].unpack('n').first.to_i
|
|
169
|
-
return unless id ==
|
|
196
|
+
return unless id == @request_id
|
|
170
197
|
|
|
171
198
|
# Check the QR value and confirm this message is a response
|
|
172
199
|
qr = message[2..2].unpack('B1').first.to_i
|
|
@@ -204,6 +231,23 @@ module Coolio
|
|
|
204
231
|
nil
|
|
205
232
|
end
|
|
206
233
|
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
def reject_ipv6_nameservers(nameservers)
|
|
237
|
+
nameservers.reject { |ns| ns.include?(':') }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The address of a nameserver, which may be given as a hostname.
|
|
241
|
+
# Only successful lookups are cached, so a transient failure is looked up again.
|
|
242
|
+
def numeric_address(nameserver)
|
|
243
|
+
@numeric_addresses[nameserver] ||= begin
|
|
244
|
+
addrinfo = Addrinfo.getaddrinfo(nameserver, nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM).first
|
|
245
|
+
raise SocketError, "getaddrinfo: no IPv4 address for #{nameserver}" if addrinfo.nil?
|
|
246
|
+
|
|
247
|
+
addrinfo.ip_address
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
207
251
|
class Timeout < TimerWatcher
|
|
208
252
|
def initialize(resolver)
|
|
209
253
|
@resolver = resolver
|
data/lib/cool.io/version.rb
CHANGED
data/libev_win_select.diff
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
diff --git a/ext/libev/ev.c b/ext/libev/ev.c
|
|
2
|
-
index
|
|
2
|
+
index a59efb2..5c18fd6 100644
|
|
3
3
|
--- a/ext/libev/ev.c
|
|
4
4
|
+++ b/ext/libev/ev.c
|
|
5
|
-
@@ -
|
|
5
|
+
@@ -210,6 +210,13 @@
|
|
6
6
|
#else
|
|
7
7
|
# include <io.h>
|
|
8
8
|
# define WIN32_LEAN_AND_MEAN
|
|
9
|
-
|
|
9
|
+
+/* ruby.h above already pulled in winsock2.h, so fd_set may be dimensioned
|
|
10
|
+
+ * already. Defining FD_SETSIZE unconditionally here would only move the bound
|
|
11
|
+
+ * used by EV_WIN_FD_SET, not the array it indexes. Take whatever is in effect
|
|
12
|
+
+ * and only supply a default when nothing has been decided yet. */
|
|
13
|
+
+# ifndef FD_SETSIZE
|
|
14
|
+
+# define FD_SETSIZE 1024
|
|
15
|
+
+# endif
|
|
10
16
|
# include <winsock2.h>
|
|
11
17
|
# include <windows.h>
|
|
12
18
|
# ifndef EV_SELECT_IS_WINSOCKET
|
|
13
19
|
diff --git a/ext/libev/ev_select.c b/ext/libev/ev_select.c
|
|
14
|
-
index
|
|
20
|
+
index ed1fc7a..eccff2b 100644
|
|
15
21
|
--- a/ext/libev/ev_select.c
|
|
16
22
|
+++ b/ext/libev/ev_select.c
|
|
17
|
-
@@ -67,6 +67,
|
|
23
|
+
@@ -67,6 +67,64 @@
|
|
18
24
|
|
|
19
25
|
#include <string.h>
|
|
20
26
|
|
|
@@ -58,6 +64,16 @@ index f38d6ca..7050778 100644
|
|
|
58
64
|
+#define EV_WIN_FD_ZERO(set) (((fd_set *)(set))->fd_count=0)
|
|
59
65
|
+#define EV_WIN_FD_ISSET(fd, set) __WSAFDIsSet((SOCKET)(fd), (fd_set *)(set))
|
|
60
66
|
+#define EV_WIN_FD_COUNT(set) (((fd_set *)(set))->fd_count)
|
|
67
|
+
+
|
|
68
|
+
+/*
|
|
69
|
+
+fd_set is dimensioned by whatever FD_SETSIZE was in effect when winsock2.h was
|
|
70
|
+
+first pulled in, but EV_WIN_FD_SET and select_modify bound-check against the
|
|
71
|
+
+FD_SETSIZE visible here. The two are decided by separate paths, so if the bound
|
|
72
|
+
+ever exceeds the declared array we would write past the end of the allocation in
|
|
73
|
+
+select_init. Catch that at build time instead.
|
|
74
|
+
+*/
|
|
75
|
+
+typedef char coolio_fd_setsize_matches_fd_set[
|
|
76
|
+
+ (sizeof (((fd_set *)0)->fd_array) / sizeof (SOCKET) >= (size_t)FD_SETSIZE) ? 1 : -1];
|
|
61
77
|
+/* ######################################## */
|
|
62
78
|
+#else
|
|
63
79
|
+#define EV_WIN_FD_CLR FD_CLR
|
|
@@ -69,7 +85,7 @@ index f38d6ca..7050778 100644
|
|
|
69
85
|
static void
|
|
70
86
|
select_modify (EV_P_ int fd, int oev, int nev)
|
|
71
87
|
{
|
|
72
|
-
@@ -91,17 +
|
|
88
|
+
@@ -91,17 +149,17 @@ select_modify (EV_P_ int fd, int oev, int nev)
|
|
73
89
|
if ((oev ^ nev) & EV_READ)
|
|
74
90
|
#endif
|
|
75
91
|
if (nev & EV_READ)
|
|
@@ -91,7 +107,7 @@ index f38d6ca..7050778 100644
|
|
|
91
107
|
|
|
92
108
|
#else
|
|
93
109
|
|
|
94
|
-
@@ -197,8 +
|
|
110
|
+
@@ -197,8 +255,8 @@ select_poll (EV_P_ ev_tstamp timeout)
|
|
95
111
|
{
|
|
96
112
|
if (timeout)
|
|
97
113
|
{
|
|
@@ -102,7 +118,7 @@ index f38d6ca..7050778 100644
|
|
|
102
118
|
}
|
|
103
119
|
|
|
104
120
|
return;
|
|
105
|
-
@@ -230,10 +
|
|
121
|
+
@@ -230,10 +288,10 @@ select_poll (EV_P_ ev_tstamp timeout)
|
|
106
122
|
int handle = fd;
|
|
107
123
|
#endif
|
|
108
124
|
|
|
@@ -116,7 +132,7 @@ index f38d6ca..7050778 100644
|
|
|
116
132
|
#endif
|
|
117
133
|
|
|
118
134
|
if (expect_true (events))
|
|
119
|
-
@@ -
|
|
135
|
+
@@ -280,9 +338,9 @@ select_init (EV_P_ int flags)
|
|
120
136
|
backend_poll = select_poll;
|
|
121
137
|
|
|
122
138
|
#if EV_SELECT_USE_FD_SET
|
data/spec/async_watcher_spec.rb
CHANGED
|
@@ -30,7 +30,9 @@ describe Cool.io::AsyncWatcher, :env => :exclude_win do
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# ensure children are ready
|
|
33
|
-
|
|
33
|
+
# rd may be O_NONBLOCK on macOS Ruby 3.2+ (IO.pipe sets O_NONBLOCK); use read
|
|
34
|
+
# instead of sysread so EAGAIN on an empty pipe is retried transparently.
|
|
35
|
+
nr_fork.times { expect(rd.read(1)).to eq('.') }
|
|
34
36
|
|
|
35
37
|
# send our signals
|
|
36
38
|
nr_signal.times { aw.signal }
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
require 'spec_helper'
|
|
2
2
|
|
|
3
3
|
describe Cool.io::Loop do
|
|
4
|
+
# An IOWatcher that drains its pipe and then runs a user-supplied block,
|
|
5
|
+
# receiving itself as the argument.
|
|
4
6
|
class Victim < Cool.io::IOWatcher
|
|
5
|
-
def initialize(io)
|
|
6
|
-
super
|
|
7
|
+
def initialize(io, &on_readable)
|
|
8
|
+
super(io)
|
|
7
9
|
@io = io
|
|
10
|
+
@on_readable = on_readable
|
|
8
11
|
end
|
|
9
12
|
|
|
10
13
|
def on_readable
|
|
@@ -12,39 +15,103 @@ describe Cool.io::Loop do
|
|
|
12
15
|
@io.read_nonblock(1024)
|
|
13
16
|
rescue IO::WaitReadable, EOFError
|
|
14
17
|
end
|
|
18
|
+
@on_readable.call(self) if @on_readable
|
|
15
19
|
end
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
# https://github.com/socketry/cool.io/issues/87
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
#
|
|
24
|
+
# Several watchers have an event pending in the same loop iteration. When the
|
|
25
|
+
# first one dispatched detaches the others, the loop must skip their now-stale
|
|
26
|
+
# pending events instead of dispatching them to a detached watcher. Before the
|
|
27
|
+
# fix that raised "TypeError: wrong argument type nil (expected Coolio::Loop)"
|
|
28
|
+
# and could crash the VM.
|
|
29
|
+
#
|
|
30
|
+
# This is exercised deterministically within a single thread (a preceding
|
|
31
|
+
# callback detaching another watcher in the same loop cycle). The original
|
|
32
|
+
# reproduction detached from a separate thread while the loop was polling,
|
|
33
|
+
# which is an unsupported concurrent mutation of libev (not thread-safe) and
|
|
34
|
+
# crashed intermittently on macOS.
|
|
35
|
+
it "does not raise when a watcher with a pending event is detached during dispatch" do
|
|
22
36
|
iterations = 200
|
|
23
37
|
|
|
24
38
|
expect {
|
|
25
39
|
iterations.times do
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
40
|
+
coolio_loop = Cool.io::Loop.new
|
|
41
|
+
pipes = []
|
|
42
|
+
watchers = []
|
|
29
43
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
44
|
+
5.times do
|
|
45
|
+
r, w = IO.pipe
|
|
46
|
+
pipes << [r, w]
|
|
47
|
+
|
|
48
|
+
watcher = Victim.new(r) do |fired|
|
|
49
|
+
# Detach every other watcher whose event is already queued for this
|
|
50
|
+
# same loop iteration.
|
|
51
|
+
watchers.each do |other|
|
|
52
|
+
other.detach if !other.equal?(fired) && other.attached?
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
watcher.attach(coolio_loop)
|
|
56
|
+
watchers << watcher
|
|
34
57
|
|
|
35
|
-
|
|
36
|
-
sleep 0.01
|
|
37
|
-
victim_watcher.detach
|
|
58
|
+
w.write("dummy\n") # make the read end readable so an event is pending
|
|
38
59
|
end
|
|
39
60
|
|
|
40
|
-
|
|
61
|
+
coolio_loop.run_once
|
|
41
62
|
|
|
42
|
-
|
|
43
|
-
|
|
63
|
+
# Only the first dispatched watcher runs; it detaches the other four,
|
|
64
|
+
# whose pending events are then skipped.
|
|
65
|
+
expect(watchers.count(&:attached?)).to eq(1)
|
|
44
66
|
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
watchers.each { |watcher| watcher.detach if watcher.attached? }
|
|
68
|
+
pipes.each { |r, w| r.close; w.close }
|
|
47
69
|
end
|
|
48
70
|
}.not_to raise_error
|
|
49
71
|
end
|
|
72
|
+
|
|
73
|
+
class HttpHandler < Coolio::IO
|
|
74
|
+
RESPONSE = "HTTP/1.1 200 OK\r\nContent-Length: 1024\r\nConnection: close\r\n\r\n" + ("X" * 1024)
|
|
75
|
+
|
|
76
|
+
def on_connect
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def on_read(data)
|
|
80
|
+
write(RESPONSE)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def on_write_complete
|
|
84
|
+
close
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# https://github.com/socketry/cool.io/issues/89
|
|
89
|
+
it "does not cause memory leaks" do
|
|
90
|
+
port = 18989
|
|
91
|
+
loop = Coolio::Loop.default
|
|
92
|
+
|
|
93
|
+
server = Coolio::TCPServer.new('127.0.0.1', port, HttpHandler)
|
|
94
|
+
server.attach(loop)
|
|
95
|
+
|
|
96
|
+
event_thread = Thread.new { loop.run }
|
|
97
|
+
|
|
98
|
+
request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"
|
|
99
|
+
|
|
100
|
+
10.times do |iteration|
|
|
101
|
+
begin
|
|
102
|
+
sock = TCPSocket.new('127.0.0.1', port)
|
|
103
|
+
sock.write(request)
|
|
104
|
+
sock.read
|
|
105
|
+
sock.close
|
|
106
|
+
rescue => e
|
|
107
|
+
sleep 0.01
|
|
108
|
+
retry
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
server.close
|
|
113
|
+
event_thread.join
|
|
114
|
+
|
|
115
|
+
expect(loop.watchers).to be_empty
|
|
116
|
+
end
|
|
50
117
|
end
|
data/spec/dns_spec.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require File.expand_path('../spec_helper', __FILE__)
|
|
2
|
+
require 'tempfile'
|
|
2
3
|
|
|
3
4
|
VALID_DOMAIN = "google.com"
|
|
4
5
|
INVALID_DOMAIN = "gibidigibigididibitidibigitibidigitidididi.com"
|
|
@@ -55,4 +56,189 @@ describe "DNS" do
|
|
|
55
56
|
expect( Coolio::DNSResolver.hosts("localhost", file.path)).to eq @preferred_localhost_address
|
|
56
57
|
end
|
|
57
58
|
end
|
|
59
|
+
|
|
60
|
+
describe "IPv6 nameserver filtering" do
|
|
61
|
+
it "ignores IPv6 nameservers provided in arguments" do
|
|
62
|
+
resolver = Coolio::DNSResolver.new("example.com", "8.8.8.8", "2001:4860:4860::8888", "1.1.1.1")
|
|
63
|
+
|
|
64
|
+
nameservers = resolver.instance_variable_get(:@nameservers)
|
|
65
|
+
expect(nameservers).to eq(["8.8.8.8", "1.1.1.1"])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "falls back to default IPv4 config if only IPv6 addresses are provided" do
|
|
69
|
+
allow(Resolv::DNS::Config).to receive(:default_config_hash).and_return({
|
|
70
|
+
nameserver: ["8.8.4.4", "2001:4860:4860::8844"]
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
resolver = Coolio::DNSResolver.new("example.com", "2001:4860:4860::8888")
|
|
74
|
+
|
|
75
|
+
nameservers = resolver.instance_variable_get(:@nameservers)
|
|
76
|
+
expect(nameservers).to eq(["8.8.4.4"])
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
describe "nameserver normalization" do
|
|
81
|
+
let(:localhost_address) do
|
|
82
|
+
Addrinfo.getaddrinfo("localhost", nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM).first.ip_address
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "keeps the nameserver list as given" do
|
|
86
|
+
resolver = Coolio::DNSResolver.new("example.com", "localhost")
|
|
87
|
+
|
|
88
|
+
expect(resolver.instance_variable_get(:@nameservers)).to eq(["localhost"])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "queries the numeric address of a nameserver given as a hostname" do
|
|
92
|
+
resolver = Coolio::DNSResolver.new("example.com", "localhost")
|
|
93
|
+
resolver.__send__(:send_request)
|
|
94
|
+
|
|
95
|
+
expect(resolver.instance_variable_get(:@queried_addresses)).to eq([localhost_address])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "accepts responses from a nameserver which was given as a hostname" do
|
|
99
|
+
resolver = Coolio::DNSResolver.new("example.com", "localhost")
|
|
100
|
+
resolver.__send__(:send_request)
|
|
101
|
+
response = dns_response_for(resolver)
|
|
102
|
+
|
|
103
|
+
expect(
|
|
104
|
+
resolver.__send__(:solicited_response?, response, ["AF_INET", 53, localhost_address, localhost_address])
|
|
105
|
+
).to be true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "looks a nameserver up once and reuses the result on retries" do
|
|
109
|
+
resolved = Addrinfo.getaddrinfo("127.0.0.1", nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM)
|
|
110
|
+
resolver = Coolio::DNSResolver.new("example.com", "127.0.0.1")
|
|
111
|
+
|
|
112
|
+
expect(Addrinfo).to receive(:getaddrinfo).once.and_return(resolved)
|
|
113
|
+
|
|
114
|
+
3.times { resolver.__send__(:send_request) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it "does not reject an unresolvable nameserver at construction" do
|
|
118
|
+
allow(Addrinfo).to receive(:getaddrinfo).and_raise(SocketError, "getaddrinfo: Name or service not known")
|
|
119
|
+
|
|
120
|
+
expect do
|
|
121
|
+
Coolio::DNSResolver.new("example.com", "no-such-nameserver.invalid")
|
|
122
|
+
end.to_not raise_error
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it "surfaces an unresolvable nameserver as a SocketError from the request" do
|
|
126
|
+
allow(Addrinfo).to receive(:getaddrinfo).and_raise(SocketError, "getaddrinfo: Name or service not known")
|
|
127
|
+
resolver = Coolio::DNSResolver.new("example.com", "no-such-nameserver.invalid")
|
|
128
|
+
|
|
129
|
+
expect { resolver.attach(@loop) }.to raise_error(SocketError)
|
|
130
|
+
expect(@loop.watchers).to be_empty
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "recovers when a nameserver is only transiently unresolvable" do
|
|
134
|
+
resolved = Addrinfo.getaddrinfo("127.0.0.1", nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM)
|
|
135
|
+
resolver = Coolio::DNSResolver.new("example.com", "ns.example.test")
|
|
136
|
+
|
|
137
|
+
attempts = 0
|
|
138
|
+
allow(Addrinfo).to receive(:getaddrinfo) do
|
|
139
|
+
attempts += 1
|
|
140
|
+
raise SocketError, "getaddrinfo: Name or service not known" if attempts == 1
|
|
141
|
+
|
|
142
|
+
resolved
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
expect { resolver.__send__(:send_request) }.to raise_error(SocketError)
|
|
146
|
+
expect { resolver.__send__(:send_request) }.to_not raise_error
|
|
147
|
+
expect(resolver.instance_variable_get(:@queried_addresses)).to eq(["127.0.0.1"])
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
describe "response validation" do
|
|
152
|
+
let(:nameserver) { "127.0.0.1" }
|
|
153
|
+
let(:sender) { ["AF_INET", 53, nameserver, nameserver] }
|
|
154
|
+
let(:resolver) do
|
|
155
|
+
Coolio::DNSResolver.new("example.com", nameserver).tap { |r| r.__send__(:send_request) }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "uses an unpredictable transaction ID for each query" do
|
|
159
|
+
ids = 10.times.map do
|
|
160
|
+
request_id_of(Coolio::DNSResolver.new("example.com", nameserver))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
expect(ids.uniq.size).to be > 1
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it "accepts a response carrying our transaction ID from the queried nameserver" do
|
|
167
|
+
expect(
|
|
168
|
+
resolver.__send__(:solicited_response?, dns_response_for(resolver), sender)
|
|
169
|
+
).to be true
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "rejects a response carrying a different transaction ID" do
|
|
173
|
+
forged = dns_response_for(resolver, id: (request_id_of(resolver) + 1) % 65536)
|
|
174
|
+
|
|
175
|
+
expect(resolver.__send__(:solicited_response?, forged, sender)).to be false
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it "rejects a response from a source address we did not query" do
|
|
179
|
+
response = dns_response_for(resolver)
|
|
180
|
+
|
|
181
|
+
expect(
|
|
182
|
+
resolver.__send__(:solicited_response?, response, ["AF_INET", 53, "10.11.12.13", "10.11.12.13"])
|
|
183
|
+
).to be false
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "rejects a response arriving before the request was sent" do
|
|
187
|
+
unsent = Coolio::DNSResolver.new("example.com", nameserver)
|
|
188
|
+
|
|
189
|
+
expect(unsent.__send__(:solicited_response?, dns_response_for(unsent), sender)).to be false
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "rejects a response from a source port other than the DNS port" do
|
|
193
|
+
response = dns_response_for(resolver)
|
|
194
|
+
|
|
195
|
+
expect(
|
|
196
|
+
resolver.__send__(:solicited_response?, response, ["AF_INET", 4444, nameserver, nameserver])
|
|
197
|
+
).to be false
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it "rejects a truncated datagram" do
|
|
201
|
+
expect(resolver.__send__(:solicited_response?, "\0\0", sender)).to be false
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it "resolves from a response sent by the queried nameserver" do
|
|
205
|
+
response = dns_response_for(resolver, address: "1.2.3.4")
|
|
206
|
+
allow(resolver.instance_variable_get(:@socket)).to receive(:recvfrom_nonblock).and_return([response, sender])
|
|
207
|
+
|
|
208
|
+
expect(resolver).to receive(:on_success).with("1.2.3.4")
|
|
209
|
+
expect(resolver).to receive(:detach)
|
|
210
|
+
|
|
211
|
+
resolver.__send__(:on_readable)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
it "ignores a spoofed response instead of resolving or failing it" do
|
|
215
|
+
forged = dns_response_for(resolver, address: "6.6.6.6")
|
|
216
|
+
allow(resolver.instance_variable_get(:@socket)).to receive(:recvfrom_nonblock)
|
|
217
|
+
.and_return([forged, ["AF_INET", 53, "10.11.12.13", "10.11.12.13"]])
|
|
218
|
+
|
|
219
|
+
expect(resolver).to_not receive(:on_success)
|
|
220
|
+
expect(resolver).to_not receive(:on_failure)
|
|
221
|
+
expect(resolver).to_not receive(:detach)
|
|
222
|
+
|
|
223
|
+
resolver.__send__(:on_readable)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def request_id_of(resolver)
|
|
228
|
+
resolver.__send__(:request_message)[0..1].unpack('n').first
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# A response to the resolver's own query: header plus the echoed question,
|
|
232
|
+
# and an A record when an address is given.
|
|
233
|
+
def dns_response_for(resolver, id: request_id_of(resolver), address: nil)
|
|
234
|
+
question = resolver.instance_variable_get(:@question)
|
|
235
|
+
answer = if address
|
|
236
|
+
# Compressed name pointer, type A, class IN, TTL, RDLENGTH, RDATA
|
|
237
|
+
[0xc00c, 1, 1, 60, 4].pack('nnnNn') + address.split('.').map(&:to_i).pack('CCCC')
|
|
238
|
+
else
|
|
239
|
+
""
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
[id, 0x81, 0x80, 1, answer.empty? ? 0 : 1, 0, 0].pack('nCCnnnn') + question + answer
|
|
243
|
+
end
|
|
58
244
|
end
|
data/spec/iobuffer_spec.rb
CHANGED
|
@@ -31,6 +31,18 @@ describe Cool.io::Buffer do
|
|
|
31
31
|
expect(buffer << "baz").to eq "baz"
|
|
32
32
|
expect(buffer.read 3).to eq "arb"
|
|
33
33
|
end
|
|
34
|
+
|
|
35
|
+
it "raises ArgumentError for a length below one" do
|
|
36
|
+
buffer << "foo"
|
|
37
|
+
expect { buffer.read 0 }.to raise_error ArgumentError
|
|
38
|
+
expect { buffer.read(-1) }.to raise_error ArgumentError
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "clamps a length which does not fit in a C int to the buffer size" do
|
|
42
|
+
buffer << "foobar"
|
|
43
|
+
expect(buffer.read 2**31).to eq "foobar"
|
|
44
|
+
expect(buffer.size).to eq 0
|
|
45
|
+
end
|
|
34
46
|
end
|
|
35
47
|
|
|
36
48
|
describe "provides methods for performing non-blocking I/O" do
|
|
@@ -142,6 +154,25 @@ describe Cool.io::Buffer do
|
|
|
142
154
|
expect(data).to eq "foo\nbarbaz"
|
|
143
155
|
expect(buffer.to_str).to eq ""
|
|
144
156
|
end
|
|
157
|
+
|
|
158
|
+
it "raises TypeError instead of crashing when data is not a String" do
|
|
159
|
+
buffer << "hello world"
|
|
160
|
+
expect { buffer.read_frame 12345, " ".ord }.to raise_error(TypeError)
|
|
161
|
+
expect { buffer.read_frame nil, " ".ord }.to raise_error(TypeError)
|
|
162
|
+
expect { buffer.read_frame [], " ".ord }.to raise_error(TypeError)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "raises FrozenError when data is a frozen String" do
|
|
166
|
+
buffer << "hello world"
|
|
167
|
+
expect { buffer.read_frame "frozen".freeze, " ".ord }.to raise_error(FrozenError)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it "coerces objects responding to #to_str" do
|
|
171
|
+
buffer << "foo\nbar"
|
|
172
|
+
convertible = Object.new
|
|
173
|
+
def convertible.to_str; +""; end
|
|
174
|
+
expect(buffer.read_frame convertible, "\n".ord).to eq true
|
|
175
|
+
end
|
|
145
176
|
end
|
|
146
177
|
|
|
147
178
|
end
|
data/spec/stat_watcher_spec.rb
CHANGED
|
@@ -18,12 +18,31 @@ class MyStatWatcher < Cool.io::StatWatcher
|
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
def run_with_file_change(path)
|
|
21
|
+
def run_with_file_change(path, compact: false)
|
|
22
22
|
reactor = Cool.io::Loop.new
|
|
23
23
|
|
|
24
24
|
sw = MyStatWatcher.new(path)
|
|
25
25
|
sw.attach(reactor)
|
|
26
26
|
|
|
27
|
+
# libev retains the path pointer passed to ev_stat_init() for the lifetime of
|
|
28
|
+
# the watcher. If the watcher held RSTRING_PTR(@path) directly, a compaction
|
|
29
|
+
# that relocates the @path String would leave libev dereferencing a stale
|
|
30
|
+
# address on every stat. Force a maximal relocation here so the watcher keeps
|
|
31
|
+
# operating against a path buffer it owns rather than Ruby-managed memory.
|
|
32
|
+
if compact
|
|
33
|
+
if GC.respond_to?(:verify_compaction_references)
|
|
34
|
+
# verify_compaction_references moves every movable object to a fresh slot.
|
|
35
|
+
# Its keyword arguments have varied across Ruby versions, so fall back.
|
|
36
|
+
begin
|
|
37
|
+
GC.verify_compaction_references(expand_heap: true, toplevel: true)
|
|
38
|
+
rescue ArgumentError
|
|
39
|
+
GC.verify_compaction_references(expand_heap: true)
|
|
40
|
+
end
|
|
41
|
+
elsif GC.respond_to?(:compact)
|
|
42
|
+
GC.compact
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
27
46
|
tw = Cool.io::TimerWatcher.new(INTERVAL, true)
|
|
28
47
|
tw.on_timer do
|
|
29
48
|
reactor.stop if sw.accessed
|
|
@@ -69,6 +88,17 @@ describe Cool.io::StatWatcher do
|
|
|
69
88
|
expect(watcher.previous.ino).to eq(watcher.current.ino)
|
|
70
89
|
end
|
|
71
90
|
|
|
91
|
+
it "keeps firing on_change after GC compaction relocates objects" do
|
|
92
|
+
skip "GC compaction not available" unless GC.respond_to?(:compact)
|
|
93
|
+
|
|
94
|
+
watcher = run_with_file_change(TEMP_FILE_PATH, compact: true)
|
|
95
|
+
expect(watcher.accessed).to eq(true)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "raises ArgumentError when the path contains a null byte" do
|
|
99
|
+
expect { MyStatWatcher.new("foo\0bar") }.to raise_error(ArgumentError)
|
|
100
|
+
end
|
|
101
|
+
|
|
72
102
|
it "should raise when the handler does not take 2 parameters" do
|
|
73
103
|
class MyStatWatcher < Cool.io::StatWatcher
|
|
74
104
|
remove_method :on_change
|