Public Access
Updates: when an answer is the rest of the same file, and how long to try
For carrying on a download that broke (issue #54): the head's ETag and Content-Range are read, a 206 is checked to be the rest of the same file, and a policy says how many tries a download is worth. Host-tested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
#include "http_head.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "http_head.h"
|
||||
|
||||
namespace roro::release {
|
||||
|
||||
// Carrying on a download that broke (issue #54): the connection is opened again with
|
||||
// "Range: bytes=<from>-", 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
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <string>
|
||||
|
||||
#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);
|
||||
|
||||
Reference in New Issue
Block a user