Skip to content

Commit

Permalink
Byte-range support for File class.
Browse files Browse the repository at this point in the history
Allows Rack to support byte-range requests (via
the HTTP 1.1 "Range:" header) for static files,
even when sendfile is not being used.

Conforms to RFC 2616 sec. 14.35 _except_ that
multiple byte-ranges are not supported yet.
(They're parsed correctly, but the response body
would need to be a MIME multipart.)

Tested in Ruby 1.8.7 on Mac OS X 10.6.4.

Signed-off-by: raggi <jftucker@gmail.com>
  • Loading branch information
snej authored and raggi committed Oct 4, 2010
1 parent a2e420e commit 8859e5c
Show file tree
Hide file tree
Showing 2 changed files with 146 additions and 36 deletions.
120 changes: 84 additions & 36 deletions lib/rack/file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,64 +29,112 @@ def call(env)

def _call(env)
@path_info = Utils.unescape(env["PATH_INFO"])
return forbidden if @path_info.include? ".."
return fail(403, "Forbidden") if @path_info.include? ".."

@path = F.join(@root, @path_info)

begin
if F.file?(@path) && F.readable?(@path)
serving
serving(env)
else
raise Errno::EPERM
end
rescue SystemCallError
not_found
fail(404, "File not found: #{@path_info}")
end
end

def forbidden
body = "Forbidden\n"
[403, {"Content-Type" => "text/plain",
"Content-Length" => body.size.to_s,
"X-Cascade" => "pass"},
[body]]
end
def serving(env)
# NOTE:
# We check via File::size? whether this file provides size info
# via stat (e.g. /proc files often don't), otherwise we have to
# figure it out by reading the whole file into memory.
size = F.size?(@path) || Utils.bytesize(F.read(@path))

# NOTE:
# We check via File::size? whether this file provides size info
# via stat (e.g. /proc files often don't), otherwise we have to
# figure it out by reading the whole file into memory. And while
# we're at it we also use this as body then.
response = [200, {
"Last-Modified" => F.mtime(@path).httpdate,
"Content-Type" => Mime.mime_type(F.extname(@path), 'text/plain')
}, self]

def serving
if size = F.size?(@path)
body = self
ranges = File.byte_ranges(env, size)
if ranges.nil? || ranges.length > 1
# No ranges, or multiple ranges (which we don't support):
# TODO: Support multiple byte-ranges
response[0] = 200
@range = (0..size-1)
elsif ranges.empty?
# Unsatisfiable. Return error, and file size:
response = fail(416, "Byte range unsatisfiable")
response[1]["Content-Range"] = "bytes */#{size}"
return response
else
body = [F.read(@path)]
size = Utils.bytesize(body.first)
# Partial content:
@range = ranges[0]
response[0] = 206
response[1]["Content-Range"] = "bytes #{@range.begin}-#{@range.end}/#{size}"
size = @range.end - @range.begin + 1
end

[200, {
"Last-Modified" => F.mtime(@path).httpdate,
"Content-Type" => Mime.mime_type(F.extname(@path), 'text/plain'),
"Content-Length" => size.to_s
}, body]
end

def not_found
body = "File not found: #{@path_info}\n"
[404, {"Content-Type" => "text/plain",
"Content-Length" => body.size.to_s,
"X-Cascade" => "pass"},
[body]]
response[1]["Content-Length"] = size.to_s
return response
end

def each
F.open(@path, "rb") { |file|
while part = file.read(8192)
F.open(@path, "rb") do |file|
file.seek(@range.begin)
remaining_len = @range.end-@range.begin+1
while remaining_len > 0
part = file.read([8192, remaining_len].min)
break unless part
remaining_len -= part.length

yield part
end
}
end
end

# Parses the "Range:" header, if present, into an array of Range objects.
# Returns nil if the header is missing or syntactically invalid.
# Returns an empty array if none of the ranges are satisfiable.
def File.byte_ranges(env, size)
# See <http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35>
http_range = env['HTTP_RANGE']
return nil unless http_range
ranges = []
for range_spec in http_range.split(/,\s*/) do
matches = range_spec.match(/bytes=(\d*)-(\d*)/)
return nil unless matches
r0,r1 = matches[1], matches[2]
if r0.empty?
return nil if r1.empty?
# suffix-byte-range-spec, represents trailing suffix of file
r0 = [size - r1.to_i, 0].max
r1 = size - 1
else
r0 = r0.to_i
if r1.empty? then
r1 = size-1
else
r1 = r1.to_i
return nil if r1 < r0 # backwards range is syntactically invalid
r1 = size-1 if r1 >= size
end
end
ranges << (r0..r1) if r0 <= r1
end
return ranges
end

private

def fail(status, body)
body += "\n"
[status,
{"Content-Type" => "text/plain",
"Content-Length" => body.size.to_s,
"X-Cascade" => "pass"},
[body]]
end

end
end
62 changes: 62 additions & 0 deletions test/spec_file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,66 @@
body.should.respond_to :to_path
body.to_path.should.equal path
end

should "ignore missing or syntactically invalid byte ranges" do
Rack::File.byte_ranges({},500).should.equal nil
Rack::File.byte_ranges({"HTTP_RANGE" => "foobar"},500).should.equal nil
Rack::File.byte_ranges({"HTTP_RANGE" => "furlongs=123-456"},500).should.equal nil
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes="},500).should.equal nil
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=-"},500).should.equal nil
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=123,456"},500).should.equal nil
# A range of non-positive length is syntactically invalid and ignored:
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=456-123"},500).should.equal nil
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=456-455"},500).should.equal nil
end

should "parse simple byte ranges" do
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=123-456"},500).should.equal [(123..456)]
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=123-"},500).should.equal [(123..499)]
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=-100"},500).should.equal [(400..499)]
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=0-0"},500).should.equal [(0..0)]
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=499-499"},500).should.equal [(499..499)]
end

should "truncate byte ranges" do
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=123-999"},500).should.equal [(123..499)]
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=600-999"},500).should.equal []
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=-999"},500).should.equal [(0..499)]
end

should "ignore unsatisfiable byte ranges" do
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=500-501"},500).should.equal []
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=500-"},500).should.equal []
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=999-"},500).should.equal []
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=-0"},500).should.equal []
end

should "handle byte ranges of empty files" do
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=123-456"},0).should.equal []
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=0-"},0).should.equal []
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=-100"},0).should.equal []
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=0-0"},0).should.equal []
Rack::File.byte_ranges({"HTTP_RANGE" => "bytes=-0"},0).should.equal []
end

should "return correct byte range in body" do
env = Rack::MockRequest.env_for("/cgi/test")
env["HTTP_RANGE"] = "bytes=22-33"
res = Rack::MockResponse.new(*Rack::File.new(DOCROOT).call(env))

res.status.should.equal 206
res["Content-Length"].should.equal "12"
res["Content-Range"].should.equal "bytes 22-33/193"
res.body.should.equal "-*- ruby -*-"
end

should "return error for unsatisfiable byte range" do
env = Rack::MockRequest.env_for("/cgi/test")
env["HTTP_RANGE"] = "bytes=1234-5678"
res = Rack::MockResponse.new(*Rack::File.new(DOCROOT).call(env))

res.status.should.equal 416
res["Content-Range"].should.equal "bytes */193"
end

end

0 comments on commit 8859e5c

Please sign in to comment.