summary refs log tree commit
diff options
context:
space:
mode:
authorJens Alfke <jens@mooseyard.com>2010-10-03 17:08:49 -0700
committerraggi <jftucker@gmail.com>2010-10-03 21:36:37 -0300
commit8859e5c35cac9631ed55bfab904bf7028db3260c (patch)
treeb79ce26dfdf9b4bde742b37b586a04a1d2819a8d
parenta2e420e1d2730487c95e1d9e5471a28e5c6cf32e (diff)
downloadrack-8859e5c35cac9631ed55bfab904bf7028db3260c.tar.gz
Byte-range support for File class.
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>
-rw-r--r--lib/rack/file.rb120
-rw-r--r--test/spec_file.rb62
2 files changed, 146 insertions, 36 deletions
diff --git a/lib/rack/file.rb b/lib/rack/file.rb
index 347955f5..6524e199 100644
--- a/lib/rack/file.rb
+++ b/lib/rack/file.rb
@@ -29,64 +29,112 @@ module Rack
 
     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
diff --git a/test/spec_file.rb b/test/spec_file.rb
index f191aa03..433a2d6f 100644
--- a/test/spec_file.rb
+++ b/test/spec_file.rb
@@ -68,4 +68,66 @@ describe Rack::File do
     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