format_parser 1.7.0 → 2.0.0.pre
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/main.yml +4 -9
- data/CHANGELOG.md +6 -0
- data/format_parser.gemspec +9 -11
- data/lib/care.rb +5 -11
- data/lib/format_parser/version.rb +1 -1
- data/lib/format_parser.rb +8 -11
- data/lib/io_utils.rb +2 -6
- data/lib/parsers/aac_parser/adts_header_info.rb +3 -9
- data/lib/parsers/dpx_parser/dpx_structs.rb +1 -1
- data/lib/parsers/exif_parser.rb +2 -4
- data/lib/parsers/fdx_parser.rb +2 -2
- data/lib/parsers/flac_parser.rb +2 -6
- data/lib/parsers/jpeg_parser.rb +2 -2
- data/lib/parsers/moov_parser.rb +5 -7
- data/lib/parsers/mp3_parser.rb +2 -6
- data/lib/parsers/mpeg_parser.rb +1 -3
- data/lib/parsers/wav_parser.rb +9 -12
- data/lib/parsers/zip_parser/file_reader.rb +45 -70
- data/lib/parsers/zip_parser.rb +1 -1
- data/lib/read_limiter.rb +8 -16
- data/lib/remote_io.rb +64 -34
- data/lib/string.rb +9 -0
- data/spec/attributes_json_spec.rb +0 -3
- data/spec/remote_fetching_spec.rb +3 -8
- data/spec/remote_io_spec.rb +116 -60
- metadata +40 -79
data/lib/remote_io.rb
CHANGED
@@ -1,14 +1,14 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
|
1
3
|
# Acts as a wrapper for turning a given URL into an IO object
|
2
|
-
# you can read from and seek in.
|
3
|
-
# to perform fetches, so if you apply Faraday configuration
|
4
|
-
# tweaks using `Faraday.default_connection = ...` these will
|
5
|
-
# take effect for these RemoteIO objects as well
|
4
|
+
# you can read from and seek in.
|
6
5
|
class FormatParser::RemoteIO
|
7
6
|
class UpstreamError < StandardError
|
8
7
|
# @return Integer
|
9
8
|
attr_reader :status_code
|
9
|
+
|
10
10
|
def initialize(status_code, message)
|
11
|
-
@status_code = status_code
|
11
|
+
@status_code = Integer(status_code)
|
12
12
|
super(message)
|
13
13
|
end
|
14
14
|
end
|
@@ -23,13 +23,19 @@ class FormatParser::RemoteIO
|
|
23
23
|
class InvalidRequest < UpstreamError
|
24
24
|
end
|
25
25
|
|
26
|
-
#
|
26
|
+
# Represents a failure where the maximum amount of
|
27
|
+
# redirect requests are exceeded.
|
28
|
+
class RedirectLimitReached < UpstreamError
|
29
|
+
def initialize(uri)
|
30
|
+
super(504, "Too many redirects; last one to: #{uri}")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# @param uri[String, URI::Generic] the remote URL to obtain
|
27
35
|
# @param headers[Hash] (optional) the HTTP headers to be used in the HTTP request
|
28
36
|
def initialize(uri, headers: {})
|
29
|
-
require 'faraday'
|
30
|
-
require 'faraday_middleware/response/follow_redirects'
|
31
37
|
@headers = headers
|
32
|
-
@uri = uri
|
38
|
+
@uri = URI(uri)
|
33
39
|
@pos = 0
|
34
40
|
@remote_size = false
|
35
41
|
end
|
@@ -63,7 +69,7 @@ class FormatParser::RemoteIO
|
|
63
69
|
# @return [String] the read bytes
|
64
70
|
def read(n_bytes)
|
65
71
|
http_range = (@pos..(@pos + n_bytes - 1))
|
66
|
-
maybe_size, maybe_body = Measurometer.instrument('format_parser.
|
72
|
+
maybe_size, maybe_body = Measurometer.instrument('format_parser.remote_io.read') { request_range(http_range) }
|
67
73
|
if maybe_size && maybe_body
|
68
74
|
@remote_size = maybe_size
|
69
75
|
@pos += maybe_body.bytesize
|
@@ -73,23 +79,39 @@ class FormatParser::RemoteIO
|
|
73
79
|
|
74
80
|
protected
|
75
81
|
|
82
|
+
REDIRECT_LIMIT = 3
|
83
|
+
UNSAFE_URI_CHARS = %r{[^\-_.!~*'()a-zA-Z\d;/?:@&=+$,\[\]%]}
|
84
|
+
|
85
|
+
# Generate the URI to fetch from following a redirect response.
|
86
|
+
#
|
87
|
+
# @param location[String] The new URI reference, as provided by the Location header of the previous response.
|
88
|
+
# @param previous_uri[URI] The URI used in the previous request.
|
89
|
+
def redirect_uri(location, previous_uri)
|
90
|
+
# Escape unsafe characters in location. Use location as new URI if absolute, otherwise use it to replace the path of
|
91
|
+
# the previous URI.
|
92
|
+
new_uri = previous_uri.merge(location.to_s.gsub(UNSAFE_URI_CHARS) do |unsafe_char|
|
93
|
+
"%#{unsafe_char.unpack('H2' * unsafe_char.bytesize).join('%').upcase}"
|
94
|
+
end)
|
95
|
+
# Keep previous URI's fragment if not present in location (https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.2-5)
|
96
|
+
new_uri.fragment = previous_uri.fragment unless new_uri.fragment
|
97
|
+
new_uri
|
98
|
+
end
|
99
|
+
|
76
100
|
# Only used internally when reading the remote file
|
77
101
|
#
|
78
|
-
# @param range[Range]
|
79
|
-
# @
|
80
|
-
|
102
|
+
# @param range[Range] The HTTP range of data to fetch from remote
|
103
|
+
# @param uri[URI] The URI to fetch from
|
104
|
+
# @param redirects[Integer] The amount of remaining permitted redirects
|
105
|
+
# @return [[Integer, String]] The response body of the ranged request
|
106
|
+
def request_range(range, uri = @uri, redirects = REDIRECT_LIMIT)
|
81
107
|
# We use a GET and not a HEAD request followed by a GET because
|
82
108
|
# S3 does not allow HEAD requests if you only presigned your URL for GETs, so we
|
83
109
|
# combine the first GET of a segment and retrieving the size of the resource
|
84
|
-
|
85
|
-
|
86
|
-
# we still need the default adapter, more details: https://blog.thecodewhisperer.com/permalink/losing-time-to-faraday
|
87
|
-
faraday.adapter Faraday.default_adapter
|
110
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
111
|
+
http.request_get(uri, @headers.merge({ 'range' => 'bytes=%d-%d' % [range.begin, range.end] }))
|
88
112
|
end
|
89
|
-
response
|
90
|
-
|
91
|
-
case response.status
|
92
|
-
when 200
|
113
|
+
case response
|
114
|
+
when Net::HTTPOK
|
93
115
|
# S3 returns 200 when you request a Range that is fully satisfied by the entire object,
|
94
116
|
# we take that into account here. Also, for very tiny responses (and also for empty responses)
|
95
117
|
# the responses are going to be 200 which does not mean we cannot proceed
|
@@ -100,16 +122,16 @@ class FormatParser::RemoteIO
|
|
100
122
|
error_message = [
|
101
123
|
"We requested #{requested_range_size} bytes, but the server sent us more",
|
102
124
|
"(#{response_size} bytes) - it likely has no `Range:` support.",
|
103
|
-
"The error occurred when talking to #{
|
125
|
+
"The error occurred when talking to #{uri})"
|
104
126
|
]
|
105
|
-
raise InvalidRequest.new(response.
|
127
|
+
raise InvalidRequest.new(response.code, error_message.join("\n"))
|
106
128
|
end
|
107
129
|
[response_size, response.body]
|
108
|
-
when
|
130
|
+
when Net::HTTPPartialContent
|
109
131
|
# Figure out of the server supports content ranges, if it doesn't we have no
|
110
132
|
# business working with that server
|
111
|
-
range_header = response
|
112
|
-
raise InvalidRequest.new(response.
|
133
|
+
range_header = response['Content-Range']
|
134
|
+
raise InvalidRequest.new(response.code, "The server replied with 206 status but no Content-Range at #{uri}") unless range_header
|
113
135
|
|
114
136
|
# "Content-Range: bytes 0-0/307404381" is how the response header is structured
|
115
137
|
size = range_header[/\/(\d+)$/, 1].to_i
|
@@ -117,19 +139,27 @@ class FormatParser::RemoteIO
|
|
117
139
|
# If we request a _larger_ range than what can be satisfied by the server,
|
118
140
|
# the response is going to only contain what _can_ be sent and the status is also going
|
119
141
|
# to be 206
|
120
|
-
|
121
|
-
when
|
142
|
+
[size, response.body]
|
143
|
+
when Net::HTTPMovedPermanently, Net::HTTPFound, Net::HTTPSeeOther, Net::HTTPTemporaryRedirect, Net::HTTPPermanentRedirect
|
144
|
+
raise RedirectLimitReached(uri) if redirects == 0
|
145
|
+
location = response['location']
|
146
|
+
if location
|
147
|
+
request_range(range, redirect_uri(location, uri), redirects - 1)
|
148
|
+
else
|
149
|
+
raise InvalidRequest.new(response.code, "Server at #{uri} replied with a #{response.code}, indicating redirection; however, the location header was empty.")
|
150
|
+
end
|
151
|
+
when Net::HTTPRangeNotSatisfiable
|
122
152
|
# We return `nil` if we tried to read past the end of the IO,
|
123
153
|
# which satisfies the Ruby IO convention. The caller should deal with `nil` being the result of a read()
|
124
154
|
# S3 will also handily _not_ supply us with the Content-Range of the actual resource, so we
|
125
155
|
# cannot hint size with this response - at lease not when working with S3
|
126
|
-
|
127
|
-
when
|
128
|
-
Measurometer.increment_counter('format_parser.
|
129
|
-
raise IntermittentFailure.new(response.
|
156
|
+
nil
|
157
|
+
when Net::HTTPServerError
|
158
|
+
Measurometer.increment_counter('format_parser.remote_io.upstream50x_errors', 1)
|
159
|
+
raise IntermittentFailure.new(response.code, "Server at #{uri} replied with a #{response.code} and we might want to retry")
|
130
160
|
else
|
131
|
-
Measurometer.increment_counter('format_parser.
|
132
|
-
raise InvalidRequest.new(response.
|
161
|
+
Measurometer.increment_counter('format_parser.remote_io.invalid_request_errors', 1)
|
162
|
+
raise InvalidRequest.new(response.code, "Server at #{uri} replied with a #{response.code} and refused our request")
|
133
163
|
end
|
134
164
|
end
|
135
165
|
end
|
data/lib/string.rb
ADDED
@@ -106,9 +106,6 @@ describe FormatParser::AttributesJSON do
|
|
106
106
|
struct: Struct.new(:key).new('Value'),
|
107
107
|
content: "\x01\xFF\xFEb\x00i\x00r\x00d\x00s\x00 \x005\x00 \x00m\x00o\x00r\x00e\x00 \x00c\x00o\x00m\x00p\x00".b
|
108
108
|
}
|
109
|
-
expect {
|
110
|
-
JSON.pretty_generate(nasty_hash) # Should not raise an error
|
111
|
-
}.to raise_error(Encoding::UndefinedConversionError)
|
112
109
|
|
113
110
|
anon_class = Struct.new(:evil)
|
114
111
|
anon_class.include FormatParser::AttributesJSON
|
@@ -124,14 +124,9 @@ describe 'Fetching data from HTTP remotes' do
|
|
124
124
|
end
|
125
125
|
|
126
126
|
it 'sends provided HTTP headers in the request' do
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
FormatParser.parse_http('invalid_url') rescue nil
|
131
|
-
|
132
|
-
expect(Faraday)
|
133
|
-
.to receive(:new)
|
134
|
-
.with(headers: {'test-header' => 'test-value'})
|
127
|
+
expect_any_instance_of(Net::HTTP)
|
128
|
+
.to receive(:request_get)
|
129
|
+
.with(anything, a_hash_including('test-header' => 'test-value'))
|
135
130
|
.and_call_original
|
136
131
|
|
137
132
|
file_information = FormatParser.parse_http(
|
data/spec/remote_io_spec.rb
CHANGED
@@ -4,130 +4,186 @@ describe FormatParser::RemoteIO do
|
|
4
4
|
it_behaves_like 'an IO object compatible with IOConstraint'
|
5
5
|
|
6
6
|
it 'returns the partial content when the server supplies a 206 status' do
|
7
|
-
|
7
|
+
url = 'https://images.invalid/img.jpg'
|
8
|
+
response = Net::HTTPPartialContent.new('2', '206', 'Partial Content')
|
9
|
+
response['Content-Range'] = '10-109/2577'
|
10
|
+
allow(response).to receive(:body).and_return('Response body')
|
8
11
|
|
9
|
-
|
10
|
-
|
11
|
-
allow(Faraday).to receive(:new).and_return(faraday_conn)
|
12
|
-
expect(faraday_conn).to receive(:get).with('https://images.invalid/img.jpg', nil, range: 'bytes=10-109')
|
12
|
+
allow(Net::HTTP).to receive(:start).and_yield(Net::HTTP).and_return(response)
|
13
|
+
allow(Net::HTTP).to receive(:request_get).and_return(response)
|
13
14
|
|
15
|
+
expect(Net::HTTP).to receive(:request_get).with(
|
16
|
+
an_object_satisfying { |uri| URI::HTTPS === uri && uri.to_s == url },
|
17
|
+
a_hash_including('range' => 'bytes=10-109')
|
18
|
+
)
|
19
|
+
|
20
|
+
rio = described_class.new(url)
|
14
21
|
rio.seek(10)
|
15
22
|
read_result = rio.read(100)
|
16
|
-
|
23
|
+
|
24
|
+
expect(read_result).to eq(response.body)
|
17
25
|
end
|
18
26
|
|
19
27
|
it 'returns the entire content when the server supplies the Content-Range response but sends a 200 status' do
|
20
|
-
|
28
|
+
url = 'https://images.invalid/img.jpg'
|
29
|
+
response = Net::HTTPOK.new('2', '200', 'OK')
|
30
|
+
allow(response).to receive(:body).and_return('Response body')
|
31
|
+
|
32
|
+
allow(Net::HTTP).to receive(:start).and_yield(Net::HTTP).and_return(response)
|
33
|
+
allow(Net::HTTP).to receive(:request_get).and_return(response)
|
21
34
|
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
35
|
+
expect(Net::HTTP).to receive(:request_get).with(
|
36
|
+
an_object_satisfying { |uri| URI::HTTPS === uri && uri.to_s == url },
|
37
|
+
a_hash_including('range' => 'bytes=10-109')
|
38
|
+
)
|
26
39
|
|
40
|
+
rio = described_class.new(url)
|
27
41
|
rio.seek(10)
|
28
42
|
read_result = rio.read(100)
|
29
|
-
|
43
|
+
|
44
|
+
expect(read_result).to eq(response.body)
|
30
45
|
end
|
31
46
|
|
32
47
|
it 'raises a specific error for all 4xx responses except 416' do
|
33
|
-
|
48
|
+
url = 'https://images.invalid/img.jpg'
|
49
|
+
response = Net::HTTPForbidden.new('2', '403', 'Forbidden')
|
50
|
+
|
51
|
+
allow(Net::HTTP).to receive(:start).and_yield(Net::HTTP).and_return(response)
|
52
|
+
allow(Net::HTTP).to receive(:request_get).and_return(response)
|
34
53
|
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
54
|
+
expect(Net::HTTP).to receive(:request_get).with(
|
55
|
+
an_object_satisfying { |uri| uri.to_s == url },
|
56
|
+
a_hash_including('range' => 'bytes=100-199')
|
57
|
+
)
|
39
58
|
|
59
|
+
rio = described_class.new(url)
|
40
60
|
rio.seek(100)
|
61
|
+
|
41
62
|
expect { rio.read(100) }.to raise_error(/replied with a 403 and refused/)
|
42
63
|
end
|
43
64
|
|
44
65
|
it 'returns nil on a 416 response' do
|
45
|
-
|
66
|
+
url = 'https://images.invalid/img.jpg'
|
67
|
+
response = Net::HTTPRangeNotSatisfiable.new('2', '416', 'Range Not Satisfiable')
|
46
68
|
|
47
|
-
|
48
|
-
|
49
|
-
allow(Faraday).to receive(:new).and_return(faraday_conn)
|
50
|
-
expect(faraday_conn).to receive(:get).with('https://images.invalid/img.jpg', nil, range: 'bytes=100-199')
|
69
|
+
allow(Net::HTTP).to receive(:start).and_yield(Net::HTTP).and_return(response)
|
70
|
+
allow(Net::HTTP).to receive(:request_get).and_return(response)
|
51
71
|
|
72
|
+
expect(Net::HTTP).to receive(:request_get).with(
|
73
|
+
an_object_satisfying { |uri| uri.to_s == url },
|
74
|
+
a_hash_including('range' => 'bytes=100-199')
|
75
|
+
)
|
76
|
+
|
77
|
+
rio = described_class.new(url)
|
52
78
|
rio.seek(100)
|
79
|
+
|
53
80
|
expect(rio.read(100)).to be_nil
|
54
81
|
end
|
55
82
|
|
56
83
|
it 'sets the status_code of the exception on a 4xx response from upstream' do
|
57
|
-
|
84
|
+
url = 'https://images.invalid/img.jpg'
|
85
|
+
response = Net::HTTPForbidden.new('2', '403', 'Forbidden')
|
86
|
+
|
87
|
+
allow(Net::HTTP).to receive(:start).and_yield(Net::HTTP).and_return(response)
|
88
|
+
allow(Net::HTTP).to receive(:request_get).and_return(response)
|
58
89
|
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
90
|
+
expect(Net::HTTP).to receive(:request_get).with(
|
91
|
+
an_object_satisfying { |uri| uri.to_s == url },
|
92
|
+
a_hash_including('range' => 'bytes=100-199')
|
93
|
+
)
|
63
94
|
|
95
|
+
rio = described_class.new(url)
|
64
96
|
rio.seek(100)
|
65
|
-
|
66
|
-
expect { rio.read(100) }.to raise_error { |e| expect(e.status_code).to eq(403) }
|
97
|
+
expect { rio.read(100) }.to(raise_error { |e| expect(e.status_code).to eq(403) })
|
67
98
|
end
|
68
99
|
|
69
100
|
it 'returns a nil when the range cannot be satisfied and the response is 416' do
|
70
|
-
|
101
|
+
url = 'https://images.invalid/img.jpg'
|
102
|
+
response = Net::HTTPRangeNotSatisfiable.new('2', '416', 'Range Not Satisfiable')
|
71
103
|
|
72
|
-
|
73
|
-
|
74
|
-
allow(Faraday).to receive(:new).and_return(faraday_conn)
|
75
|
-
expect(faraday_conn).to receive(:get).with('https://images.invalid/img.jpg', nil, range: 'bytes=100-199')
|
104
|
+
allow(Net::HTTP).to receive(:start).and_yield(Net::HTTP).and_return(response)
|
105
|
+
allow(Net::HTTP).to receive(:request_get).and_return(response)
|
76
106
|
|
107
|
+
expect(Net::HTTP).to receive(:request_get).with(
|
108
|
+
an_object_satisfying { |uri| uri.to_s == url },
|
109
|
+
a_hash_including('range' => 'bytes=100-199')
|
110
|
+
)
|
111
|
+
|
112
|
+
rio = described_class.new(url)
|
77
113
|
rio.seek(100)
|
114
|
+
|
78
115
|
expect(rio.read(100)).to be_nil
|
79
116
|
end
|
80
117
|
|
81
118
|
it 'does not overwrite size when the range cannot be satisfied and the response is 416' do
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
allow(
|
89
|
-
|
90
|
-
|
119
|
+
url = 'https://images.invalid/img.jpg'
|
120
|
+
response_1 = Net::HTTPPartialContent.new('2', '206', 'Partial Content')
|
121
|
+
response_1['Content-Range'] = 'bytes 0-0/13'
|
122
|
+
allow(response_1).to receive(:body).and_return('Response body')
|
123
|
+
response_2 = Net::HTTPRangeNotSatisfiable.new('2', '416', 'Range Not Satisfiable')
|
124
|
+
|
125
|
+
allow(Net::HTTP).to receive(:start).and_yield(Net::HTTP).and_return(response_1, response_2)
|
126
|
+
allow(Net::HTTP).to receive(:request_get).and_return(response_1, response_2)
|
127
|
+
|
128
|
+
expect(Net::HTTP).to receive(:request_get)
|
129
|
+
.with(
|
130
|
+
an_object_satisfying { |uri| uri.to_s == url },
|
131
|
+
a_hash_including('range' => 'bytes=0-0')
|
132
|
+
)
|
91
133
|
.ordered
|
92
|
-
|
93
|
-
|
94
|
-
|
134
|
+
expect(Net::HTTP).to receive(:request_get)
|
135
|
+
.with(
|
136
|
+
an_object_satisfying { |uri| uri.to_s == url },
|
137
|
+
a_hash_including('range' => 'bytes=100-199')
|
138
|
+
)
|
95
139
|
.ordered
|
96
|
-
.and_return(fake_resp2)
|
97
140
|
|
141
|
+
rio = described_class.new(url)
|
98
142
|
rio.read(1)
|
99
143
|
|
100
144
|
expect(rio.size).to eq(13)
|
101
145
|
|
102
146
|
rio.seek(100)
|
103
|
-
expect(rio.read(100)).to be_nil
|
104
147
|
|
148
|
+
expect(rio.read(100)).to be_nil
|
105
149
|
expect(rio.size).to eq(13)
|
106
150
|
end
|
107
151
|
|
108
152
|
it 'raises a specific error for all 5xx responses' do
|
109
|
-
|
153
|
+
url = 'https://images.invalid/img.jpg'
|
154
|
+
response = Net::HTTPBadGateway.new('2', '502', 'Bad Gateway')
|
155
|
+
|
156
|
+
allow(Net::HTTP).to receive(:start).and_yield(Net::HTTP).and_return(response)
|
157
|
+
allow(Net::HTTP).to receive(:request_get).and_return(response)
|
110
158
|
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
159
|
+
expect(Net::HTTP).to receive(:request_get).with(
|
160
|
+
an_object_satisfying { |uri| uri.to_s == url },
|
161
|
+
a_hash_including('range' => 'bytes=100-199')
|
162
|
+
)
|
115
163
|
|
164
|
+
rio = described_class.new(url)
|
116
165
|
rio.seek(100)
|
166
|
+
|
117
167
|
expect { rio.read(100) }.to raise_error(/replied with a 502 and we might want to retry/)
|
118
168
|
end
|
119
169
|
|
120
170
|
it 'maintains and exposes #pos' do
|
121
|
-
|
171
|
+
url = 'https://images.invalid/img.jpg'
|
172
|
+
response = Net::HTTPPartialContent.new('2', '206', 'Partial Content')
|
173
|
+
response['Content-Range'] = 'bytes 0-0/13'
|
174
|
+
allow(response).to receive(:body).and_return('a')
|
122
175
|
|
123
|
-
|
176
|
+
allow(Net::HTTP).to receive(:start).and_yield(Net::HTTP).and_return(response)
|
177
|
+
allow(Net::HTTP).to receive(:request_get).and_return(response)
|
124
178
|
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
rio.read(1)
|
179
|
+
expect(Net::HTTP).to receive(:request_get).with(
|
180
|
+
an_object_satisfying { |uri| uri.to_s == url },
|
181
|
+
a_hash_including('range' => 'bytes=0-0')
|
182
|
+
)
|
130
183
|
|
184
|
+
rio = described_class.new(url)
|
185
|
+
expect(rio.pos).to eq(0)
|
186
|
+
rio.read(1)
|
131
187
|
expect(rio.pos).to eq(1)
|
132
188
|
end
|
133
189
|
end
|