diff --git a/lib/release/src/http_head.cpp b/lib/release/src/http_head.cpp index 05b385c..df41930 100644 --- a/lib/release/src/http_head.cpp +++ b/lib/release/src/http_head.cpp @@ -1,5 +1,7 @@ #include "http_head.h" +#include + #include #include #include @@ -58,6 +60,15 @@ void HttpHeadParser::line() { else if (name == "transfer-encoding") head_.chunked = lower(value).find("chunked") != std::string::npos; else if (name == "location") head_.location = value; else if (name == "content-type") head_.contentType = value; + else if (name == "etag") head_.etag = value; + else if (name == "content-range") { + long from = -1, to = -1, total = -1; + if (std::sscanf(lower(value).c_str(), "bytes %ld-%ld/%ld", &from, &to, &total) == 3 && from >= 0 && to >= from && total > to) { + head_.rangeFrom = from; + head_.rangeTo = to; + head_.rangeTotal = total; + } + } } size_t ChunkedDecoder::decode(const uint8_t* in, size_t len, uint8_t* out) { diff --git a/lib/release/src/http_head.h b/lib/release/src/http_head.h index d9243e2..e3cdbe6 100644 --- a/lib/release/src/http_head.h +++ b/lib/release/src/http_head.h @@ -12,6 +12,9 @@ struct HttpHead { long contentLength = -1; // -1: not given bool chunked = false; std::string location, contentType; + std::string etag; // as sent, quotes included; "" if none + // "Content-Range: bytes 1000-2999/3000", the answer to a range request; -1: not given. + long rangeFrom = -1, rangeTo = -1, rangeTotal = -1; }; class HttpHeadParser { diff --git a/lib/release/src/resume.h b/lib/release/src/resume.h new file mode 100644 index 0000000..772439d --- /dev/null +++ b/lib/release/src/resume.h @@ -0,0 +1,53 @@ +#pragma once + +#include + +#include "http_head.h" + +namespace roro::release { + +// Carrying on a download that broke (issue #54): the connection is opened again with +// "Range: bytes=-", and what was already received stays where it is. The Update File's own +// hash and signature are what decide whether the whole is good; these checks only keep the device +// from carrying on with something that plainly is not the rest of the same file. + +// The validator to send as "If-Range", so a file that changed comes back whole (a 200) instead of +// in part: only a strong ETag may be used for that. "" for none. +inline std::string ifRangeFor(const std::string& etag) { + return etag.size() >= 2 && etag.front() == '"' && etag.back() == '"' ? etag : ""; +} + +// Is this answer the rest of the file, from byte `from` of `total`? "" if so, or why not, in +// words for the screen. `etag` is what the first answer gave ("" if nothing). +inline std::string resumeRefusal(const HttpHead& head, long from, long total, const std::string& etag) { + if (head.status == 200) return "The file changed on the server"; + if (head.status != 206) return "The server answered " + std::to_string(head.status); + if (head.chunked || head.rangeFrom != from) return "The server sent another part of the file"; + if (head.rangeTotal != total || head.rangeTo != total - 1 || head.contentLength != total - from) return "The file changed size on the server"; + if (!etag.empty() && !head.etag.empty() && head.etag != etag) return "The file changed on the server"; + return ""; +} + +// How long to keep trying. A break that came after new bytes is the link being poor, and is +// worth another try; breaks with nothing in between mean it is not coming back. +class ResumePolicy { + public: + static constexpr int kMaxResumes = 20; // in one download + static constexpr int kMaxStuck = 3; // in a row without a byte + + // The connection broke with `received` bytes of the file here: try again? + bool again(long received) { + stuck_ = received > last_ ? 0 : stuck_ + 1; + last_ = received; + if (resumes_ >= kMaxResumes || stuck_ >= kMaxStuck) return false; + resumes_++; + return true; + } + int resumes() const { return resumes_; } + + private: + long last_ = 0; + int resumes_ = 0, stuck_ = 0; +}; + +} // namespace roro::release diff --git a/test/test_http/test_http.cpp b/test/test_http/test_http.cpp index 6e6139d..eecde72 100644 --- a/test/test_http/test_http.cpp +++ b/test/test_http/test_http.cpp @@ -3,6 +3,7 @@ #include #include "http_head.h" +#include "resume.h" using namespace roro::release; @@ -42,6 +43,92 @@ void test_a_download_head() { TEST_ASSERT_EQUAL_STRING("https://elsewhere.example/x", r.head().location.c_str()); } +void test_a_part_of_a_file() { + std::string head = "HTTP/1.1 206 Partial Content\r\nContent-Range: bytes 1000-2510383/2510384\r\n" + "ETag: \"ac948b3f\"\r\nContent-Length: 2509384\r\n\r\n"; + HttpHeadParser p; + p.feed(head.data(), head.size()); + TEST_ASSERT_EQUAL_INT(206, p.head().status); + TEST_ASSERT_EQUAL_INT(1000, p.head().rangeFrom); + TEST_ASSERT_EQUAL_INT(2510383, p.head().rangeTo); + TEST_ASSERT_EQUAL_INT(2510384, p.head().rangeTotal); + TEST_ASSERT_EQUAL_STRING("\"ac948b3f\"", p.head().etag.c_str()); + // A range that makes no sense is no range. + for (const char* bad : {"bytes */2510384", "bytes 5-2/10", "bytes 0-10/10", "items 0-1/2", ""}) { + std::string h = std::string("HTTP/1.1 206 Partial Content\r\nContent-Range: ") + bad + "\r\n\r\n"; + HttpHeadParser q; + q.feed(h.data(), h.size()); + TEST_ASSERT_EQUAL_INT(-1, q.head().rangeFrom); + } +} + +static HttpHead part(long from, long total, const char* etag = "\"abc\"") { + HttpHead h; + h.status = 206; + h.rangeFrom = from; + h.rangeTo = total - 1; + h.rangeTotal = total; + h.contentLength = total - from; + h.etag = etag; + return h; +} + +void test_the_rest_of_the_same_file_is_taken() { + TEST_ASSERT_EQUAL_STRING("", resumeRefusal(part(1000, 5000), 1000, 5000, "\"abc\"").c_str()); + TEST_ASSERT_EQUAL_STRING("", resumeRefusal(part(1000, 5000, ""), 1000, 5000, "\"abc\"").c_str()); // no ETag this time + TEST_ASSERT_EQUAL_STRING("", resumeRefusal(part(1000, 5000), 1000, 5000, "").c_str()); // none the first time +} + +void test_anything_else_is_refused() { + HttpHead whole; + whole.status = 200; // If-Range did not match: the file is another one now + whole.contentLength = 5000; + TEST_ASSERT_EQUAL_STRING("The file changed on the server", resumeRefusal(whole, 1000, 5000, "\"abc\"").c_str()); + HttpHead gone; + gone.status = 404; + TEST_ASSERT_EQUAL_STRING("The server answered 404", resumeRefusal(gone, 1000, 5000, "").c_str()); + TEST_ASSERT_FALSE(resumeRefusal(part(0, 5000), 1000, 5000, "\"abc\"").empty()); // from the start + TEST_ASSERT_FALSE(resumeRefusal(part(1000, 6000), 1000, 5000, "\"abc\"").empty()); // another size + TEST_ASSERT_FALSE(resumeRefusal(part(1000, 5000, "\"xyz\""), 1000, 5000, "\"abc\"").empty()); + HttpHead shortPart = part(1000, 5000); + shortPart.rangeTo = 2000; // not up to the end + TEST_ASSERT_FALSE(resumeRefusal(shortPart, 1000, 5000, "\"abc\"").empty()); + HttpHead chunked = part(1000, 5000); + chunked.chunked = true; + TEST_ASSERT_FALSE(resumeRefusal(chunked, 1000, 5000, "\"abc\"").empty()); +} + +void test_only_a_strong_etag_goes_in_if_range() { + TEST_ASSERT_EQUAL_STRING("\"abc\"", ifRangeFor("\"abc\"").c_str()); + TEST_ASSERT_EQUAL_STRING("", ifRangeFor("W/\"abc\"").c_str()); + TEST_ASSERT_EQUAL_STRING("", ifRangeFor("").c_str()); +} + +void test_how_long_a_download_is_tried() { + ResumePolicy poor; // a poor link: each try brings some more + for (int i = 0; i < ResumePolicy::kMaxResumes; i++) TEST_ASSERT_TRUE(poor.again(1000 * (i + 1))); + TEST_ASSERT_FALSE(poor.again(1000000)); + TEST_ASSERT_EQUAL_INT(ResumePolicy::kMaxResumes, poor.resumes()); + + ResumePolicy dead; // the link is gone: nothing more comes + TEST_ASSERT_TRUE(dead.again(5000)); + TEST_ASSERT_TRUE(dead.again(5000)); + TEST_ASSERT_TRUE(dead.again(5000)); + TEST_ASSERT_FALSE(dead.again(5000)); + + ResumePolicy fromNothing; // not even a first byte + TEST_ASSERT_TRUE(fromNothing.again(0)); + TEST_ASSERT_TRUE(fromNothing.again(0)); + TEST_ASSERT_FALSE(fromNothing.again(0)); + + ResumePolicy recovering; // stuck twice, then bytes again: the count starts over + recovering.again(5000); + recovering.again(5000); + recovering.again(5000); + TEST_ASSERT_TRUE(recovering.again(9000)); + TEST_ASSERT_TRUE(recovering.again(9000)); +} + void test_a_head_that_is_not_http() { HttpHeadParser p; std::string junk = "\x16\x03\x01 not http at all\r\n\r\n"; @@ -114,6 +201,11 @@ int main() { UNITY_BEGIN(); RUN_TEST(test_a_head_in_pieces); RUN_TEST(test_a_download_head); + RUN_TEST(test_a_part_of_a_file); + RUN_TEST(test_the_rest_of_the_same_file_is_taken); + RUN_TEST(test_anything_else_is_refused); + RUN_TEST(test_only_a_strong_etag_goes_in_if_range); + RUN_TEST(test_how_long_a_download_is_tried); RUN_TEST(test_a_head_that_is_not_http); RUN_TEST(test_chunked_bodies); RUN_TEST(test_urls);