blob: 9e5037299b64f7a342568c78b98c63e4f29cb0c2 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#include "iceberg/catalog/rest/http_client.h"
#include <array>
#include <atomic>
#include <cctype>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <format>
#include <string>
#include <string_view>
#include <thread>
#include <utility>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#ifndef _WIN32
# include <poll.h>
# include <unistd.h>
# include <arpa/inet.h>
# include <netinet/in.h>
# include <sys/socket.h>
# include <sys/time.h>
#endif
#include "iceberg/catalog/rest/auth/auth_session.h"
#include "iceberg/catalog/rest/error_handlers.h"
#include "iceberg/test/matchers.h"
namespace iceberg::rest {
#ifndef _WIN32
namespace {
/// \brief Minimal HTTP/1.1 server on 127.0.0.1 that answers every request with
/// a fixed response and closes the connection.
class LoopbackHttpServer {
public:
explicit LoopbackHttpServer(std::string response) : response_(std::move(response)) {}
~LoopbackHttpServer() {
stop_ = true;
if (thread_.joinable()) {
thread_.join();
}
if (listen_fd_ >= 0) {
close(listen_fd_);
}
}
LoopbackHttpServer(const LoopbackHttpServer&) = delete;
LoopbackHttpServer& operator=(const LoopbackHttpServer&) = delete;
/// \brief Bind an ephemeral port and start serving. Returns false on failure.
bool Start() {
listen_fd_ = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd_ < 0) {
return false;
}
int reuse = 1;
setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = 0;
if (bind(listen_fd_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0 ||
listen(listen_fd_, 8) != 0) {
return false;
}
socklen_t len = sizeof(addr);
if (getsockname(listen_fd_, reinterpret_cast<sockaddr*>(&addr), &len) != 0) {
return false;
}
port_ = ntohs(addr.sin_port);
thread_ = std::thread([this] { Serve(); });
return true;
}
int port() const { return port_; }
int request_count() const { return request_count_.load(); }
std::string url(std::string_view path) const {
return std::format("http://127.0.0.1:{}{}", port_, path);
}
private:
void Serve() {
while (!stop_) {
pollfd pfd{.fd = listen_fd_, .events = POLLIN, .revents = 0};
if (poll(&pfd, 1, /*timeout_ms=*/50) <= 0) {
continue;
}
int conn = accept(listen_fd_, nullptr, nullptr);
if (conn < 0) {
continue;
}
ReadRequest(conn);
++request_count_;
SendAll(conn, response_);
close(conn);
}
}
/// \brief Consume the request headers and any Content-Length body so the
/// client does not see a connection reset before it reads the response.
static void ReadRequest(int conn) {
timeval timeout{.tv_sec = 10, .tv_usec = 0};
setsockopt(conn, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
setsockopt(conn, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
std::string request;
std::array<char, 4096> buf{};
size_t header_end = std::string::npos;
size_t content_length = 0;
while (true) {
ssize_t n = recv(conn, buf.data(), buf.size(), 0);
if (n <= 0) {
return;
}
request.append(buf.data(), static_cast<size_t>(n));
if (header_end == std::string::npos) {
header_end = request.find("\r\n\r\n");
if (header_end == std::string::npos) {
continue;
}
content_length = ParseContentLength(request.substr(0, header_end));
}
if (request.size() >= header_end + 4 + content_length) {
return;
}
}
}
static size_t ParseContentLength(std::string headers) {
for (auto& c : headers) {
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
constexpr std::string_view kHeader = "content-length:";
size_t pos = headers.find(kHeader);
if (pos == std::string::npos) {
return 0;
}
return std::stoul(headers.substr(pos + kHeader.size()));
}
static void SendAll(int conn, const std::string& data) {
# ifdef MSG_NOSIGNAL
constexpr int kFlags = MSG_NOSIGNAL;
# else
constexpr int kFlags = 0;
int no_sigpipe = 1;
setsockopt(conn, SOL_SOCKET, SO_NOSIGPIPE, &no_sigpipe, sizeof(no_sigpipe));
# endif
size_t sent = 0;
while (sent < data.size()) {
ssize_t n = send(conn, data.data() + sent, data.size() - sent, kFlags);
if (n <= 0) {
return;
}
sent += static_cast<size_t>(n);
}
}
std::string response_;
int listen_fd_ = -1;
int port_ = 0;
std::atomic<bool> stop_{false};
std::atomic<int> request_count_{0};
std::thread thread_;
};
// A loopback socket listening on an ephemeral port.
struct Listener {
int fd = -1;
uint16_t port = 0;
~Listener() {
if (fd >= 0) {
::close(fd);
}
}
};
void Listen(Listener& listener) {
listener.fd = ::socket(AF_INET, SOCK_STREAM, 0);
ASSERT_GE(listener.fd, 0);
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
ASSERT_EQ(::bind(listener.fd, reinterpret_cast<sockaddr*>(&address), sizeof(address)),
0);
ASSERT_EQ(::listen(listener.fd, 1), 0);
socklen_t length = sizeof(address);
ASSERT_EQ(::getsockname(listener.fd, reinterpret_cast<sockaddr*>(&address), &length),
0);
listener.port = ntohs(address.sin_port);
}
// Accepts one connection, reads the request head and sends `response`. Then
// ends the connection if `end`, or else waits for the client to close it.
void ServeOnce(int listener, std::string_view response, bool end) {
// Bound every wait, so the test cannot hang.
timeval wait{};
wait.tv_sec = 10;
::setsockopt(listener, SOL_SOCKET, SO_RCVTIMEO, &wait, sizeof(wait));
const int connection = ::accept(listener, nullptr, nullptr);
if (connection < 0) {
return;
}
::setsockopt(connection, SOL_SOCKET, SO_RCVTIMEO, &wait, sizeof(wait));
std::string request;
std::array<char, 1024> buffer;
while (request.find("\r\n\r\n") == std::string::npos) {
const auto received = ::recv(connection, buffer.data(), buffer.size(), 0);
if (received <= 0) {
break;
}
request.append(buffer.data(), static_cast<size_t>(received));
}
::send(connection, response.data(), response.size(), 0);
if (end) {
::shutdown(connection, SHUT_WR);
}
// Drain until the client closes, so ours closes cleanly.
while (::recv(connection, buffer.data(), buffer.size(), 0) > 0) {
}
::close(connection);
}
} // namespace
TEST(HttpClientTest, GetFailsWhenItsTransferFails) {
// A 200 arriving first does not make a truncated response a success.
constexpr std::string_view kTruncated =
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
"Content-Length: 100\r\n\r\n{";
struct Case {
std::string_view name;
std::string_view response;
bool end;
std::string_view error;
};
for (const auto& test_case :
{Case{.name = "no response", .response = "", .end = false, .error = "timed out"},
Case{.name = "body stalls",
.response = kTruncated,
.end = false,
.error = "timed out"},
Case{.name = "body ends early",
.response = kTruncated,
.end = true,
.error = "failed after status 200"}}) {
SCOPED_TRACE(test_case.name);
Listener listener;
ASSERT_NO_FATAL_FAILURE(Listen(listener));
const auto url = std::format("http://127.0.0.1:{}/v1/config", listener.port);
HttpClient client({}, {}, std::chrono::milliseconds(500));
auto session = auth::AuthSession::MakeDefault({});
std::thread server(
[&] { ServeOnce(listener.fd, test_case.response, test_case.end); });
auto response = client.Get(url, /*params=*/{}, /*headers=*/{},
*DefaultErrorHandler::Instance(), *session);
server.join();
EXPECT_THAT(response, IsError(ErrorKind::kIOError));
EXPECT_THAT(response, HasErrorMessage(test_case.error));
}
}
TEST(HttpClientTest, PostReportsTransferFailureAfterSuccessfulStatus) {
// A write that got its status may have taken effect, so the caller must treat
// the result as unknown rather than retrying or cleaning up automatically.
constexpr std::string_view kBody = R"({"metadata-location":"s3://bucket/m.json"})";
const auto response_bytes = std::format(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
"Transfer-Encoding: chunked\r\n\r\n{:x}\r\n{}\r\n",
kBody.size(), kBody);
Listener listener;
ASSERT_NO_FATAL_FAILURE(Listen(listener));
const auto url = std::format("http://127.0.0.1:{}/v1/commit", listener.port);
// Outlives the response, which keeps a curl handle in its connection pool.
HttpClient client;
auto session = auth::AuthSession::MakeDefault({});
std::thread server([&] { ServeOnce(listener.fd, response_bytes, /*end=*/true); });
auto response =
client.Post(url, "{}", /*headers=*/{}, *DefaultErrorHandler::Instance(), *session);
server.join();
EXPECT_THAT(response, IsError(ErrorKind::kIOError));
EXPECT_THAT(response, HasErrorMessage("failed after status 200"));
}
#endif
// Redirect responses must be reported without replaying the request at the target.
TEST(HttpClientTest, DoesNotFollowRedirects) {
#ifdef _WIN32
GTEST_SKIP() << "Loopback test server is only implemented for POSIX";
#else
for (const int status_code : {301, 302, 303, 307, 308}) {
SCOPED_TRACE(status_code);
LoopbackHttpServer target(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
"Content-Length: 2\r\nConnection: close\r\n\r\n{}");
ASSERT_TRUE(target.Start());
LoopbackHttpServer redirector(
std::format("HTTP/1.1 {} Redirect\r\nLocation: {}\r\nContent-Length: 0\r\n"
"Connection: close\r\n\r\n",
status_code, target.url("/v1/config")));
ASSERT_TRUE(redirector.Start());
HttpClient client({}, std::chrono::seconds(10), std::chrono::seconds(10));
auto session = auth::AuthSession::MakeDefault(
{{"Authorization", "Bearer token"},
{"Original-Authorization", "Bearer delegate-token"},
{"X-Amz-Security-Token", "session-token"}});
const auto& handler = *DefaultErrorHandler::Instance();
const std::string url = redirector.url("/v1/config");
auto expect_not_followed = [&](const Result<HttpResponse>& result,
std::string_view method) {
SCOPED_TRACE(method);
EXPECT_THAT(result, IsError(ErrorKind::kRestError));
EXPECT_THAT(result, HasErrorMessage(std::format("code: {}", status_code)));
EXPECT_EQ(target.request_count(), 0);
};
expect_not_followed(client.Get(url, {}, {}, handler, *session), "GET");
expect_not_followed(client.Post(url, "{}", {}, handler, *session), "POST");
expect_not_followed(client.PostForm(url, {{"grant_type", "client_credentials"}}, {},
handler, *session),
"POST form");
expect_not_followed(client.Head(url, {}, handler, *session), "HEAD");
expect_not_followed(client.Delete(url, {}, {}, handler, *session), "DELETE");
EXPECT_EQ(redirector.request_count(), 5);
}
#endif
}
} // namespace iceberg::rest