eldritch horrors
b22f2bc8e9
file:// urls are no longer handled by curl itself, and this is supposed
to test the curl wrapper. use http to force the wrapper to be involved.
Change-Id: Ib13087db07b3b2f1ae44ce8e3ec7a96d935b1bab
396 lines
13 KiB
C++
396 lines
13 KiB
C++
#include "filetransfer.hh"
|
|
#include "compression.hh"
|
|
|
|
#include <cstdint>
|
|
#include <exception>
|
|
#include <future>
|
|
#include <gtest/gtest.h>
|
|
#include <netinet/in.h>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <sys/poll.h>
|
|
#include <sys/socket.h>
|
|
#include <thread>
|
|
#include <unistd.h>
|
|
|
|
// local server tests don't work on darwin without some incantations
|
|
// the horrors do not want to look up. contributions welcome though!
|
|
#if __APPLE__
|
|
#define NOT_ON_DARWIN(n) DISABLED_##n
|
|
#else
|
|
#define NOT_ON_DARWIN(n) n
|
|
#endif
|
|
|
|
using namespace std::chrono_literals;
|
|
|
|
namespace {
|
|
|
|
struct Reply {
|
|
std::string status, headers;
|
|
std::function<std::optional<std::string>(int)> content;
|
|
std::list<std::string> expectedHeaders;
|
|
|
|
Reply(
|
|
std::string_view status,
|
|
std::string_view headers,
|
|
std::function<std::string()> content,
|
|
std::list<std::string> expectedHeaders = {}
|
|
)
|
|
: Reply(
|
|
status,
|
|
headers,
|
|
[content](int round) { return round == 0 ? std::optional(content()) : std::nullopt; },
|
|
std::move(expectedHeaders)
|
|
)
|
|
{
|
|
}
|
|
|
|
Reply(
|
|
std::string_view status,
|
|
std::string_view headers,
|
|
std::function<std::optional<std::string>(int)> content,
|
|
std::list<std::string> expectedHeaders = {}
|
|
)
|
|
: status(status)
|
|
, headers(headers)
|
|
, content(content)
|
|
, expectedHeaders(std::move(expectedHeaders))
|
|
{
|
|
}
|
|
};
|
|
}
|
|
|
|
namespace nix {
|
|
|
|
static std::tuple<uint16_t, AutoCloseFD>
|
|
serveHTTP(std::vector<Reply> replies)
|
|
{
|
|
AutoCloseFD listener(::socket(AF_INET6, SOCK_STREAM, 0));
|
|
if (!listener) {
|
|
throw SysError(errno, "socket() failed");
|
|
}
|
|
|
|
Pipe trigger;
|
|
trigger.create();
|
|
|
|
sockaddr_in6 addr = {
|
|
.sin6_family = AF_INET6,
|
|
.sin6_addr = IN6ADDR_LOOPBACK_INIT,
|
|
};
|
|
socklen_t len = sizeof(addr);
|
|
if (::bind(listener.get(), reinterpret_cast<const sockaddr *>(&addr), sizeof(addr)) < 0) {
|
|
throw SysError(errno, "bind() failed");
|
|
}
|
|
if (::getsockname(listener.get(), reinterpret_cast<sockaddr *>(&addr), &len) < 0) {
|
|
throw SysError(errno, "getsockname() failed");
|
|
}
|
|
if (::listen(listener.get(), 1) < 0) {
|
|
throw SysError(errno, "listen() failed");
|
|
}
|
|
|
|
std::thread(
|
|
[replies, at{0}](AutoCloseFD socket, AutoCloseFD trigger) mutable {
|
|
while (true) {
|
|
pollfd pfds[2] = {
|
|
{
|
|
.fd = socket.get(),
|
|
.events = POLLIN,
|
|
},
|
|
{
|
|
.fd = trigger.get(),
|
|
.events = POLLHUP,
|
|
},
|
|
};
|
|
|
|
if (::poll(pfds, 2, -1) <= 0) {
|
|
throw SysError(errno, "poll() failed");
|
|
}
|
|
if (pfds[1].revents & POLLHUP) {
|
|
return;
|
|
}
|
|
if (!(pfds[0].revents & POLLIN)) {
|
|
continue;
|
|
}
|
|
|
|
AutoCloseFD conn(::accept(socket.get(), nullptr, nullptr));
|
|
if (!conn) {
|
|
throw SysError(errno, "accept() failed");
|
|
}
|
|
|
|
const auto & reply = replies[at++ % replies.size()];
|
|
|
|
std::thread([=, conn{std::move(conn)}] {
|
|
auto send = [&](std::string_view bit) {
|
|
while (!bit.empty()) {
|
|
auto written = ::send(conn.get(), bit.data(), bit.size(), MSG_NOSIGNAL);
|
|
if (written < 0) {
|
|
debug("send() failed: %s", strerror(errno));
|
|
return;
|
|
}
|
|
bit.remove_prefix(written);
|
|
}
|
|
};
|
|
|
|
send("HTTP/1.1 ");
|
|
send(reply.status);
|
|
send("\r\n");
|
|
|
|
std::string requestWithHeaders;
|
|
while (true) {
|
|
char c;
|
|
if (recv(conn.get(), &c, 1, MSG_NOSIGNAL) != 1) {
|
|
debug("recv() failed for headers: %s", strerror(errno));
|
|
return;
|
|
}
|
|
requestWithHeaders += c;
|
|
if (requestWithHeaders.ends_with("\r\n\r\n")) {
|
|
requestWithHeaders.resize(requestWithHeaders.size() - 2);
|
|
break;
|
|
}
|
|
}
|
|
debug("got request:\n%s", requestWithHeaders);
|
|
for (auto & expected : reply.expectedHeaders) {
|
|
ASSERT_TRUE(requestWithHeaders.contains(fmt("%s\r\n", expected)));
|
|
}
|
|
|
|
send(reply.headers);
|
|
send("\r\n");
|
|
for (int round = 0; ; round++) {
|
|
if (auto content = reply.content(round); content.has_value()) {
|
|
send(*content);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
::shutdown(conn.get(), SHUT_WR);
|
|
for (;;) {
|
|
char buf[1];
|
|
switch (recv(conn.get(), buf, 1, MSG_NOSIGNAL)) {
|
|
case 0:
|
|
return; // remote closed
|
|
case 1:
|
|
continue; // connection still held open by remote
|
|
default:
|
|
debug("recv() failed: %s", strerror(errno));
|
|
return;
|
|
}
|
|
}
|
|
}).detach();
|
|
}
|
|
},
|
|
std::move(listener),
|
|
std::move(trigger.readSide)
|
|
)
|
|
.detach();
|
|
|
|
return {
|
|
ntohs(addr.sin6_port),
|
|
std::move(trigger.writeSide),
|
|
};
|
|
}
|
|
|
|
static std::tuple<uint16_t, AutoCloseFD>
|
|
serveHTTP(std::string status, std::string headers, std::function<std::string()> content)
|
|
{
|
|
return serveHTTP({{{status, headers, content}}});
|
|
}
|
|
|
|
TEST(FileTransfer, exceptionAbortsDownload)
|
|
{
|
|
struct Done : std::exception
|
|
{};
|
|
|
|
auto ft = makeFileTransfer();
|
|
|
|
LambdaSink broken([](auto block) { throw Done(); });
|
|
|
|
auto [port, srv] = serveHTTP({{"200 ok", "", [](int) { return "foo"; }}});
|
|
ASSERT_THROW(ft->download(fmt("http://[::1]:%d/index", port)).second->drainInto(broken), Done);
|
|
|
|
// makeFileTransfer returns a ref<>, which cannot be cleared. since we also
|
|
// can't default-construct it we'll have to overwrite it instead, but we'll
|
|
// take the raw pointer out first so we can destroy it in a detached thread
|
|
// (otherwise a failure will stall the process and have it killed by meson)
|
|
auto reset = std::async(std::launch::async, [&]() { ft = makeFileTransfer(); });
|
|
EXPECT_EQ(reset.wait_for(10s), std::future_status::ready);
|
|
// if this did time out we have to leak `reset`.
|
|
if (reset.wait_for(0s) == std::future_status::timeout) {
|
|
(void) new auto(std::move(reset));
|
|
}
|
|
}
|
|
|
|
TEST(FileTransfer, exceptionAbortsRead)
|
|
{
|
|
auto [port, srv] = serveHTTP("200 ok", "content-length: 0\r\n", [] { return ""; });
|
|
auto ft = makeFileTransfer();
|
|
char buf[10] = "";
|
|
ASSERT_THROW(ft->download(fmt("http://[::1]:%d/index", port)).second->read(buf, 10), EndOfFile);
|
|
}
|
|
|
|
TEST(FileTransfer, NOT_ON_DARWIN(reportsSetupErrors))
|
|
{
|
|
auto [port, srv] = serveHTTP("404 not found", "", [] { return ""; });
|
|
auto ft = makeFileTransfer();
|
|
ASSERT_THROW(
|
|
ft->download(fmt("http://[::1]:%d/index", port)),
|
|
FileTransferError
|
|
);
|
|
}
|
|
|
|
TEST(FileTransfer, NOT_ON_DARWIN(defersFailures))
|
|
{
|
|
auto [port, srv] = serveHTTP("200 ok", "content-length: 100000000\r\n", [] {
|
|
std::this_thread::sleep_for(10ms);
|
|
// just a bunch of data to fill the curl wrapper buffer, otherwise the
|
|
// initial wait for header data will also wait for the the response to
|
|
// complete (the source is only woken when curl returns data, and curl
|
|
// might only do so once its internal buffer has already been filled.)
|
|
return std::string(1024 * 1024, ' ');
|
|
});
|
|
auto ft = makeFileTransfer(0);
|
|
auto src = ft->download(fmt("http://[::1]:%d/index", port)).second;
|
|
ASSERT_THROW(src->drain(), FileTransferError);
|
|
}
|
|
|
|
TEST(FileTransfer, NOT_ON_DARWIN(handlesContentEncoding))
|
|
{
|
|
std::string original = "Test data string";
|
|
std::string compressed = compress("gzip", original);
|
|
|
|
auto [port, srv] = serveHTTP("200 ok", "content-encoding: gzip\r\n", [&] { return compressed; });
|
|
auto ft = makeFileTransfer();
|
|
|
|
StringSink sink;
|
|
ft->download(fmt("http://[::1]:%d/index", port)).second->drainInto(sink);
|
|
EXPECT_EQ(sink.s, original);
|
|
}
|
|
|
|
TEST(FileTransfer, usesIntermediateLinkHeaders)
|
|
{
|
|
auto [port, srv] = serveHTTP({
|
|
{"301 ok",
|
|
"location: /second\r\n"
|
|
"content-length: 0\r\n",
|
|
[] { return ""; }},
|
|
{"307 ok",
|
|
"location: /third\r\n"
|
|
"content-length: 0\r\n",
|
|
[] { return ""; }},
|
|
{"307 ok",
|
|
"location: /fourth\r\n"
|
|
"link: <http://foo>; rel=\"immutable\"\r\n"
|
|
"content-length: 0\r\n",
|
|
[] { return ""; }},
|
|
{"200 ok", "content-length: 1\r\n", [] { return "a"; }},
|
|
});
|
|
auto ft = makeFileTransfer(0);
|
|
auto [result, _data] = ft->download(fmt("http://[::1]:%d/first", port));
|
|
ASSERT_EQ(result.immutableUrl, "http://foo");
|
|
}
|
|
|
|
TEST(FileTransfer, stalledReaderDoesntBlockOthers)
|
|
{
|
|
auto [port, srv] = serveHTTP({
|
|
{"200 ok",
|
|
"content-length: 100000000\r\n",
|
|
[](int round) mutable {
|
|
return round < 100 ? std::optional(std::string(1'000'000, ' ')) : std::nullopt;
|
|
}},
|
|
});
|
|
auto ft = makeFileTransfer(0);
|
|
auto [_result1, data1] = ft->download(fmt("http://[::1]:%d", port));
|
|
auto [_result2, data2] = ft->download(fmt("http://[::1]:%d", port));
|
|
auto drop = [](Source & source, size_t size) {
|
|
char buf[1000];
|
|
while (size > 0) {
|
|
auto round = std::min(size, sizeof(buf));
|
|
source(buf, round);
|
|
size -= round;
|
|
}
|
|
};
|
|
// read 10M of each of the 100M, then the rest. neither reader should
|
|
// block the other, nor should it take that long to copy 200MB total.
|
|
drop(*data1, 10'000'000);
|
|
drop(*data2, 10'000'000);
|
|
drop(*data1, 90'000'000);
|
|
drop(*data2, 90'000'000);
|
|
|
|
ASSERT_THROW(drop(*data1, 1), EndOfFile);
|
|
ASSERT_THROW(drop(*data2, 1), EndOfFile);
|
|
}
|
|
|
|
TEST(FileTransfer, retries)
|
|
{
|
|
auto [port, srv] = serveHTTP({
|
|
// transient setup failure
|
|
{"429 try again later", "content-length: 0\r\n", [] { return ""; }},
|
|
// transient transfer failure (simulates a connection break)
|
|
{"200 ok",
|
|
"content-length: 2\r\n"
|
|
"accept-ranges: bytes\r\n",
|
|
[] { return "a"; }},
|
|
// wrapper should ask for remaining data now
|
|
{"200 ok",
|
|
"content-length: 1\r\n"
|
|
"content-range: bytes 1-1/2\r\n",
|
|
[] { return "b"; },
|
|
{"Range: bytes=1-"}},
|
|
});
|
|
auto ft = makeFileTransfer(0);
|
|
auto [result, data] = ft->download(fmt("http://[::1]:%d", port));
|
|
ASSERT_EQ(data->drain(), "ab");
|
|
}
|
|
|
|
TEST(FileTransfer, doesntRetrySetupForever)
|
|
{
|
|
auto [port, srv] = serveHTTP({
|
|
{"429 try again later", "content-length: 0\r\n", [] { return ""; }},
|
|
});
|
|
auto ft = makeFileTransfer(0);
|
|
ASSERT_THROW(ft->download(fmt("http://[::1]:%d", port)), FileTransferError);
|
|
}
|
|
|
|
TEST(FileTransfer, doesntRetryTransferForever)
|
|
{
|
|
constexpr size_t LIMIT = 20;
|
|
ASSERT_LT(fileTransferSettings.tries, LIMIT); // just to keep test runtime low
|
|
std::vector<Reply> replies;
|
|
for (size_t i = 0; i < LIMIT; i++) {
|
|
replies.emplace_back(
|
|
"200 ok",
|
|
fmt("content-length: %1%\r\n"
|
|
"accept-ranges: bytes\r\n"
|
|
"content-range: bytes %2%-%3%/%3%\r\n",
|
|
LIMIT - i,
|
|
i,
|
|
LIMIT),
|
|
[] { return "a"; }
|
|
);
|
|
}
|
|
auto [port, srv] = serveHTTP(replies);
|
|
auto ft = makeFileTransfer(0);
|
|
ASSERT_THROW(ft->download(fmt("http://[::1]:%d", port)).second->drain(), FileTransferError);
|
|
}
|
|
|
|
TEST(FileTransfer, doesntRetryUploads)
|
|
{
|
|
auto ft = makeFileTransfer(0);
|
|
|
|
{
|
|
auto [port, srv] = serveHTTP({
|
|
{"429 try again later", "", [] { return ""; }},
|
|
{"200 ok", "", [] { return ""; }},
|
|
});
|
|
ASSERT_THROW(ft->upload(fmt("http://[::1]:%d", port), ""), FileTransferError);
|
|
}
|
|
{
|
|
auto [port, srv] = serveHTTP({
|
|
{"429 try again later", "", [] { return ""; }},
|
|
{"200 ok", "", [] { return ""; }},
|
|
});
|
|
ASSERT_THROW(ft->upload(fmt("http://[::1]:%d", port), "foo"), FileTransferError);
|
|
}
|
|
}
|
|
|
|
}
|