diff options
author | Eric Wong <normalperson@yhbt.net> | 2012-02-08 02:07:07 +0000 |
---|---|---|
committer | Eric Wong <normalperson@yhbt.net> | 2012-02-08 22:32:32 +0000 |
commit | b74796a50f400419db19faccd69c433f9380beda (patch) | |
tree | 5bf5ffb42f5e9291166622bd8af17ae45c08cc0d | |
parent | 16038a4993f7156f04c221b045debfec706d4cfd (diff) | |
download | cmogstored-b74796a50f400419db19faccd69c433f9380beda.tar.gz |
This allows clients to support partial requests and resume downloads.
-rw-r--r-- | Makefile.am | 2 | ||||
-rw-r--r-- | cmogstored.h | 1 | ||||
-rw-r--r-- | http_get.c | 180 | ||||
-rw-r--r-- | http_parser.rl | 22 | ||||
-rw-r--r-- | test/http-parser-1.c | 56 | ||||
-rw-r--r-- | test/http_chunked_put.rb | 4 | ||||
-rw-r--r-- | test/http_range.rb | 185 |
7 files changed, 413 insertions, 37 deletions
diff --git a/Makefile.am b/Makefile.am index 3f19c68..8b24f03 100644 --- a/Makefile.am +++ b/Makefile.am @@ -101,7 +101,7 @@ RUBY = ruby TEST_EXTENSIONS = .rb .slowrb RB_LOG_COMPILER = $(RUBY) -I$(top_srcdir) -w SLOWRB_LOG_COMPILER = RUBY="$(RUBY)" top_srcdir="$(top_srcdir)" -RB_TESTS_FAST = test/cmogstored-cfg.rb test/http_dav.rb +RB_TESTS_FAST = test/cmogstored-cfg.rb test/http_dav.rb test/http_range.rb RB_TESTS_SLOW = test/mgmt-usage.rb test/mgmt.rb test/mgmt-iostat.rb \ test/http.rb test/http_put.rb test/http_chunked_put.rb RB_TESTS = $(RB_TESTS_FAST) $(RB_TESTS_SLOW) diff --git a/cmogstored.h b/cmogstored.h index 84b8082..40bfe2d 100644 --- a/cmogstored.h +++ b/cmogstored.h @@ -163,6 +163,7 @@ struct mog_http { uint8_t path_end; uint16_t line_end; uint16_t tmp_tip; + uint16_t range_bytes_tip; struct mog_fd *forward; size_t offset; off_t range_beg; @@ -34,15 +34,145 @@ static ssize_t linux_sendfile(int sockfd, int filefd, off_t *off, size_t count) # include "compat_sendfile.h" #endif -void mog_http_get_open(struct mog_http *http, char *buf, bool head_only) +#define ERR416 "416 Requested Range Not Satisfiable" + +/* TODO: refactor this */ +static void http_get_resp_hdr(struct mog_http *http, struct stat *sb) { - struct stat sb; + char *modified; struct iovec iov; + struct mog_now *now = mog_now(); int rc; - char *path = mog_http_path(http, buf); - char *modified; - struct mog_now *now; + /* single buffer so we can use MSG_MORE later... */ + /* TODO: remove reliance on snprintf(), it eats stack */ + iov.iov_base = mog_fsbuf_get(&iov.iov_len); + modified = (char *)iov.iov_base + iov.iov_len / 2; + assert(iov.iov_len / 2 > MOG_HTTPDATE_CAPA && "fsbuf too small"); + mog_http_date(modified, MOG_HTTPDATE_CAPA, &sb->st_mtime); + + /* validate ranges */ + if (http->range_bytes_tip && ! http->has_range) + goto bad_range; + if (http->has_range) { + long long offset, count; + + assert(http->range_bytes_tip && "range_bytes_tip not set"); + + if (http->range_end < 0 && http->range_beg < 0) + goto bad_range; + if (http->range_beg >= sb->st_size) + goto bad_range; + + /* bytes=M-N where M > N */ + if (http->range_beg >= 0 && http->range_end >= 0 + && http->range_beg > http->range_end) + goto bad_range; + + if (http->range_end < 0) { /* bytes=M- */ + /* bytes starting at M until EOF */ + assert(http->range_beg >= 0 && "should've sent 416"); + offset = (long long)http->range_beg; + count = (long long)(sb->st_size - offset); + } else if (http->range_beg < 0) { /* bytes=-N */ + /* last N bytes */ + assert(http->range_end >= 0 && "should've sent 416"); + offset = (long long)(sb->st_size - http->range_end); + + /* serve the entire file if client requested too much */ + if (offset < 0) + goto resp_200; + count = (long long)(sb->st_size - offset); + } else { /* bytes=M-N*/ + assert(http->range_beg >= 0 && http->range_end >= 0 + && "should've sent 416"); + offset = (long long)http->range_beg; + + /* truncate responses to current file size */ + if (http->range_end >= sb->st_size) + http->range_end = sb->st_size - 1; + count = (long long)http->range_end + 1 - offset; + } + + assert(count > 0 && "bad count for 206 response"); + assert(offset >= 0 && "bad offset for 206 response"); + + if (http->forward) { + struct mog_file *file = &http->forward->as.file; + + file->foff = offset; + file->fsize = (off_t)(offset + count); + } + + rc = snprintf(iov.iov_base, iov.iov_len, + "HTTP/1.1 206 Partial Content\r\n" + "Status: 206 Partial Content\r\n" + "Date: %s\r\n" + "Last-Modified: %s\r\n" + "Content-Length: %lld\r\n" + "Content-Type: application/octet-stream\r\n" + "Content-Range: bytes %lld-%lld/%lld\n" + "Connection: %s\r\n" + "\r\n", + now->httpdate, + modified, + count, /* Content-Length */ + offset, offset + count - 1, /* bytes M-N */ + (long long)sb->st_size, + http->persistent ? "keep-alive" : "close"); + } else { +resp_200: + rc = snprintf(iov.iov_base, iov.iov_len, + "HTTP/1.1 200 OK\r\n" + "Status: 200 OK\r\n" + "Date: %s\r\n" + "Last-Modified: %s\r\n" + "Content-Length: %lld\r\n" + "Content-Type: application/octet-stream\r\n" + "Accept-Ranges: bytes\r\n" + "Connection: %s\r\n" + "\r\n", + now->httpdate, + modified, + (long long)sb->st_size, + http->persistent ? "keep-alive" : "close"); + } + + /* TODO: put down the crack pipe and refactor this */ + if (0) { +bad_range: + if (http->forward) { + mog_file_close(http->forward); + http->forward = NULL; + } else { + assert(http->http_method == MOG_HTTP_METHOD_HEAD + && "not HTTP HEAD"); + } + rc = snprintf(iov.iov_base, iov.iov_len, + "HTTP/1.1 " ERR416 "\r\n" + "Status: " ERR416 "\r\n" + "Date: %s\r\n" + "Content-Length: 0\r\n" + "Content-Type: text/plain\r\n" + "Content-Range: bytes */%lld\n" + "Connection: %s\r\n" + "\r\n", + now->httpdate, + (long long)sb->st_size, + http->persistent ? "keep-alive" : "close"); + } + + assert(rc > 0 && rc < iov.iov_len && "we suck at snprintf"); + iov.iov_len = rc; + assert(http->wbuf == NULL && "tried to write to a busy client"); + + http->wbuf = mog_trywritev(mog_fd_of(http)->fd, &iov, 1); +} + +void mog_http_get_open(struct mog_http *http, char *buf, bool head_only) +{ + struct stat sb; + char *path = mog_http_path(http, buf); if (!path) goto forbidden; /* path traversal attack */ assert(http->forward == NULL && "already have http->forward"); assert(path[0] == '/' && "bad path"); @@ -75,33 +205,8 @@ void mog_http_get_open(struct mog_http *http, char *buf, bool head_only) file->fsize = sb.st_size; } - /* single buffer so we can use MSG_MORE later... */ - /* TODO: remove reliance on snprintf(), it eats stack */ - iov.iov_base = mog_fsbuf_get(&iov.iov_len); - modified = (char *)iov.iov_base + iov.iov_len / 2; - assert(iov.iov_len / 2 > MOG_HTTPDATE_CAPA && "fsbuf too small"); - mog_http_date(modified, MOG_HTTPDATE_CAPA, &sb.st_mtime); - now = mog_now(); - rc = snprintf(iov.iov_base, iov.iov_len, - "HTTP/1.1 200 OK\r\n" - "Status: 200 OK\r\n" - "Date: %s\r\n" - "Last-Modified: %s\r\n" - "Content-Length: %lld\r\n" - "Content-Type: application/octet-stream\r\n" - "Connection: %s\r\n" - "\r\n", - now->httpdate, - modified, - (long long)sb.st_size, - http->persistent ? "keep-alive" : "close"); - assert(rc > 0 && "we suck at snprintf"); - iov.iov_len = rc; - assert(http->wbuf == NULL && "tried to write to a busy client"); - - http->wbuf = mog_trywritev(mog_fd_of(http)->fd, &iov, 1); + http_get_resp_hdr(http, &sb); return; - err: switch (errno) { case EACCES: @@ -123,13 +228,13 @@ enum mog_next mog_http_get_in_progress(struct mog_fd *mfd) struct mog_fd *file_mfd; struct mog_file *file; ssize_t w; - size_t count; + off_t count; /* * most readahead is 128K, so we try to send half of that to give * the kernel time to do readahead */ - static const size_t max_sendfile = 64 * 1024; /* TODO: make tunable */ + static const off_t max_sendfile = 64 * 1024; /* TODO: make tunable */ assert(http->wbuf == NULL && "can't serve file with http->wbuf"); assert(http->forward && http->forward != MOG_IOSTAT && "bad forward"); @@ -137,10 +242,13 @@ enum mog_next mog_http_get_in_progress(struct mog_fd *mfd) file = &file_mfd->as.file; assert(file->fsize >= 0 && "fsize is negative"); - count = file->fsize > max_sendfile ? max_sendfile : file->fsize; - if (count == 0) goto done; + assert(file->foff >= 0 && "foff is negative"); + count = file->fsize - file->foff; + count = count > max_sendfile ? max_sendfile : count; + if (count == 0) + goto done; retry: - w = sendfile(mfd->fd, file_mfd->fd, &file->foff, count); + w = sendfile(mfd->fd, file_mfd->fd, &file->foff, (size_t)count); if (w > 0) { if (file->foff == file->fsize) goto done; return MOG_NEXT_ACTIVE; diff --git a/http_parser.rl b/http_parser.rl index 1458fb7..f823b43 100644 --- a/http_parser.rl +++ b/http_parser.rl @@ -64,6 +64,27 @@ static bool length_incr(off_t *len, unsigned c) $! { errno = EINVAL; fbreak; } "/*" eor > { http->has_content_range = 1; }; + range = "Range:"i sep + "bytes" > { http->range_bytes_tip = to_u16(fpc - buf); } + '=' > { + *p = ' '; + http->range_beg = -1; + http->range_end = -1; + } + (digit*) $ { + if (http->range_beg < 0) + http->range_beg = 0; + if (!length_incr(&http->range_beg, fc)) + fbreak; + } + '-' + (digit*) $ { + if (http->range_end < 0) + http->range_end = 0; + if (!length_incr(&http->range_end, fc)) + fbreak; + } + eor > { *p = 0; } @ { http->has_range = 1; }; transfer_encoding_chunked = "Transfer-Encoding:"i sep "chunked"i eor > { http->chunked = 1; }; trailer = "Trailer:"i sep @@ -77,6 +98,7 @@ static bool length_incr(off_t *len, unsigned c) ( content_length | transfer_encoding_chunked | trailer | + range | content_range | content_md5 | connection ) $! diff --git a/test/http-parser-1.c b/test/http-parser-1.c index 18aa30e..7e513f1 100644 --- a/test/http-parser-1.c +++ b/test/http-parser-1.c @@ -18,6 +18,11 @@ static void assert_path_equal(const char *str) assert(http->path_end == http->path_tip + slen); } +static void assert_range_bytes_equal(const char *str) +{ + assert(0 == strcmp(str, buf + http->range_bytes_tip)); +} + static void reset(void) { free(buf); @@ -199,6 +204,57 @@ int main(void) assert_path_equal("/foo"); } + if ("HTTP/1.1 Range (mid) GET request") { + buf_set("GET /foo HTTP/1.1\r\n" + "Host: 127.6.6.6\r\n" + "Range: bytes=5-55\r\n" + "\r\n"); + state = mog_http_parse(http, buf, len); + assert(http->has_range == 1); + assert(http->range_beg == 5 && "range_beg didn't match"); + assert(http->range_end == 55 && "range_end didn't match"); + assert(http->http_method == MOG_HTTP_METHOD_GET + && "http_method should be GET"); + assert(http->persistent == 1 && "should be persistent"); + assert(state == MOG_PARSER_DONE && "parser not done"); + assert_range_bytes_equal("bytes 5-55"); + assert_path_equal("/foo"); + } + + if ("HTTP/1.1 Range (tip) GET request") { + buf_set("GET /foo HTTP/1.1\r\n" + "Host: 127.6.6.6\r\n" + "Range: bytes=-55\r\n" + "\r\n"); + state = mog_http_parse(http, buf, len); + assert(http->has_range == 1); + assert(http->range_beg == -1 && "range_beg didn't match"); + assert(http->range_end == 55 && "range_end didn't match"); + assert(http->http_method == MOG_HTTP_METHOD_GET + && "http_method should be GET"); + assert(http->persistent == 1 && "should be persistent"); + assert(state == MOG_PARSER_DONE && "parser not done"); + assert_range_bytes_equal("bytes -55"); + assert_path_equal("/foo"); + } + + if ("HTTP/1.1 Range (end) GET request") { + buf_set("GET /foo HTTP/1.1\r\n" + "Host: 127.6.6.6\r\n" + "Range: bytes=55-\r\n" + "\r\n"); + state = mog_http_parse(http, buf, len); + assert(http->has_range == 1); + assert(http->range_beg == 55 && "range_beg didn't match"); + assert(http->range_end == -1 && "range_end didn't match"); + assert(http->http_method == MOG_HTTP_METHOD_GET + && "http_method should be GET"); + assert(http->persistent == 1 && "should be persistent"); + assert(state == MOG_PARSER_DONE && "parser not done"); + assert_range_bytes_equal("bytes 55-"); + assert_path_equal("/foo"); + } + reset(); return 0; } diff --git a/test/http_chunked_put.rb b/test/http_chunked_put.rb index d8f7b00..a492f9e 100644 --- a/test/http_chunked_put.rb +++ b/test/http_chunked_put.rb @@ -108,6 +108,7 @@ class TestHTTPChunkedPut < Test::Unit::TestCase "Last-Modified: #{EPOCH}\r\n" \ "Content-Length: 5\r\n" \ "Content-Type: application/octet-stream\r\n" \ + "Accept-Ranges: bytes\r\n" \ "Connection: close\r\n\r\nabcde" base = "PUT /zz HTTP/1.1\r\n" \ "Host: #@host:#@port\r\n" \ @@ -242,6 +243,7 @@ class TestHTTPChunkedPut < Test::Unit::TestCase "Last-Modified: #{EPOCH}\r\n" \ "Content-Length: 5\r\n" \ "Content-Type: application/octet-stream\r\n" \ + "Accept-Ranges: bytes\r\n" \ "Connection: keep-alive\r\n\r\nabcde" resp = @client.read(expect.size) replace_dates!(resp) @@ -267,6 +269,7 @@ class TestHTTPChunkedPut < Test::Unit::TestCase "Last-Modified: #{EPOCH}\r\n" \ "Content-Length: 5\r\n" \ "Content-Type: application/octet-stream\r\n" \ + "Accept-Ranges: bytes\r\n" \ "Connection: keep-alive\r\n\r\nabcde" resp = @client.read(expect.size) replace_dates!(resp) @@ -295,6 +298,7 @@ class TestHTTPChunkedPut < Test::Unit::TestCase "Last-Modified: #{EPOCH}\r\n" \ "Content-Length: 5\r\n" \ "Content-Type: application/octet-stream\r\n" \ + "Accept-Ranges: bytes\r\n" \ "Connection: keep-alive\r\n\r\nabcde" resp = @client.read(expect.size) replace_dates!(resp) diff --git a/test/http_range.rb b/test/http_range.rb new file mode 100644 index 0000000..68f8d40 --- /dev/null +++ b/test/http_range.rb @@ -0,0 +1,185 @@ +#!/usr/bin/env ruby +# -*- encoding: binary -*- +# Copyright (C) 2012, Eric Wong <normalperson@yhbt.net> +# License: GPLv3 or later (see COPYING for details) +require 'test/test_helper' +require 'net/http' +require 'time' +require 'webrick' +require 'stringio' + +class TestHTTPRange < Test::Unit::TestCase + def setup + @tmpdir = Dir.mktmpdir('cmogstored-http-test') + @to_close = [] + @host = TEST_HOST + srv = TCPServer.new(@host, 0) + @port = srv.addr[1] + srv.close + @err = Tempfile.new("stderr") + cmd = [ "cmogstored", "--docroot=#@tmpdir", "--httplisten=#@host:#@port", + "--maxconns=500" ] + vg = ENV["VALGRIND"] and cmd = vg.split(/\s+/).concat(cmd) + @pid = fork { + $stderr.reopen(@err) + @err.close + exec(*cmd) + } + @wblogger = StringIO.new + wbopts = { + :Port => 0, + :AccessLog => [], + :DocumentRoot => @tmpdir, + :ServerType => Thread, + :Logger => WEBrick::Log.new(@wblogger), + } + @webrick = WEBrick::HTTPServer.new(wbopts) + @webrick.start + @wport = @webrick.listeners[0].addr[1] + tries = 300 + begin + sleep 0.05 + @client = TCPSocket.new(@host, @port) + @to_close << @client + break + rescue + end while (tries-=1) > 0 + end + + def test_hello + full = File.open("/dev/urandom") { |fp| fp.read(666) } + File.open("#@tmpdir/hello", "wb") do |hello| + assert_equal 666, hello.write(full) + end + Net::HTTP.start(@host, @wport) do |wb| + Net::HTTP.start(@host, @port) do |cm| + [ Net::HTTP::Get, Net::HTTP::Head ].each do |meth| + r = meth.new("/hello") + cm_r = cm.request(r) + wb_r = wb.request(r) + assert_equal "666", cm_r["Content-Length"] + assert_equal "666", wb_r["Content-Length"] + assert_equal nil, cm_r["Content-Range"] + assert_equal nil, wb_r["Content-Range"] + assert_equal wb_r.body, cm_r.body + assert_equal nil, wb_r.body if Net::HTTP::Head === meth + + r = meth.new("/hello") + r["Range"] = "bytes=66-70" + cm_r = cm.request(r) + wb_r = wb.request(r) + assert_equal wb_r["Content-Length"], cm_r["Content-Length"] + assert_equal "5", cm_r["Content-Length"] + assert_equal wb_r["Content-Range"], cm_r["Content-Range"] + assert_equal "bytes 66-70/666", cm_r["Content-Range"] + assert_equal wb_r.body, cm_r.body + assert_equal nil, wb_r.body if Net::HTTP::Head === meth + + r["Range"] = "bytes=66-" + cm_r = cm.request(r) + wb_r = wb.request(r) + assert_equal wb_r["Content-Length"], cm_r["Content-Length"] + assert_equal "600", cm_r["Content-Length"] + assert_equal wb_r["Content-Range"], cm_r["Content-Range"] + assert_equal "bytes 66-665/666", cm_r["Content-Range"] + assert_equal wb_r.body, cm_r.body + assert_equal nil, wb_r.body if Net::HTTP::Head === meth + + r["Range"] = "bytes=-66" + cm_r = cm.request(r) + wb_r = wb.request(r) + assert_equal wb_r["Content-Length"], cm_r["Content-Length"] + assert_equal "66", cm_r["Content-Length"] + assert_equal wb_r["Content-Range"], cm_r["Content-Range"] + assert_equal "bytes 600-665/666", cm_r["Content-Range"] + assert_equal wb_r.body, cm_r.body + assert_equal nil, wb_r.body if Net::HTTP::Head === meth + + %w(0 665 123).each do |i| + r["Range"] = "bytes=#{i}-#{i}" + cm_r = cm.request(r) + wb_r = wb.request(r) + assert_equal wb_r["Content-Length"], cm_r["Content-Length"] + assert_equal "1", cm_r["Content-Length"] + assert_equal wb_r["Content-Range"], cm_r["Content-Range"] + assert_equal "bytes #{i}-#{i}/666", cm_r["Content-Range"] + assert_equal wb_r.body, cm_r.body + assert_equal nil, wb_r.body if Net::HTTP::Head === meth + end + + # webrick doesn't seem to support this, follow nginx + r["Range"] = "bytes=666-666" + cm_r = cm.request(r) + assert_equal "0", cm_r["Content-Length"] + assert_equal "bytes */666", cm_r["Content-Range"] + assert_equal nil, cm_r.body if Net::HTTP::Head === meth + assert_equal 416, cm_r.code.to_i + + # send the entire response as a 200 if the range is too far + # webrick doesn't seem to support this, follow nginx + %w(667 6666).each do |i| + r["Range"] = "bytes=-#{i}" + cm_r = cm.request(r) + assert_equal "666", cm_r["Content-Length"] + assert_nil cm_r["Content-Range"] + assert_equal nil, cm_r.body if Net::HTTP::Head === meth + assert_equal 200, cm_r.code.to_i + end + + # send the entire request if the range is everything + r["Range"] = "bytes=-666" + cm_r = cm.request(r) + wb_r = wb.request(r) + assert_equal wb_r["Content-Range"], cm_r["Content-Range"] + assert_equal wb_r["Content-Length"], cm_r["Content-Length"] + assert_equal "666", cm_r["Content-Length"] + assert_equal wb_r.body, cm_r.body + assert_equal nil, wb_r.body if Net::HTTP::Head === meth + assert_equal wb_r.code.to_i, cm_r.code.to_i + + # out-of-range requests + %w(666 6666).each do |i| + r["Range"] = "bytes=#{i}-" + cm_r = cm.request(r) + wb_r = wb.request(r) + assert_equal wb_r.code.to_i, cm_r.code.to_i + assert_equal 416, cm_r.code.to_i + assert_equal "bytes */666", cm_r["Content-Range"] + assert_equal nil, cm_r.body if Net::HTTP::Head === meth + end + + %w(666 6666).each do |i| + r["Range"] = "bytes=#{i}-#{i}" + cm_r = cm.request(r) + wb_r = wb.request(r) + assert_equal wb_r.code.to_i, cm_r.code.to_i + assert_equal 416, cm_r.code.to_i + assert_equal "bytes */666", cm_r["Content-Range"] + assert_equal nil, cm_r.body if Net::HTTP::Head === meth + end + + # bogus ranges + %w(-5-5 4qasdlkj 323).each do |bogus| + r["Range"] = "bytes=#{bogus}" + cm_r = cm.request(r) + assert_equal 416, cm_r.code.to_i + assert_equal "bytes */666", cm_r["Content-Range"] + assert_equal "0", cm_r["Content-Length"] + assert_equal nil, cm_r.body if Net::HTTP::Head === meth + end + end + end + end + end + + def teardown + Process.kill(:QUIT, @pid) rescue nil + _, status = Process.waitpid2(@pid) + @to_close.each { |io| io.close unless io.closed? } + FileUtils.rm_rf(@tmpdir) + @err.rewind + $stderr.write(@err.read) + assert status.success?, status.inspect + @webrick.shutdown + end +end |