Tactility/Modules/http-module/source/download_posix.cpp
2026-10-16 08:50:43 +02:00

405 lines
14 KiB
C++

// SPDX-License-Identifier: Apache-2.0
#include <http/private/download.h>
#include <http/private/http_parsing.h>
#include <mbedtls/ssl.h>
#include <mbedtls/x509_crt.h>
#include <psa/crypto.h>
#include <tactility/log.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <netdb.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>
namespace {
constexpr auto* TAG = "http-download";
constexpr int TIMEOUT_SECONDS = 5;
constexpr size_t BUFFER_SIZE = 512;
// A write to a closed connection must fail instead of raising SIGPIPE. macOS has no MSG_NOSIGNAL, but SO_NOSIGPIPE on the socket.
#ifdef MSG_NOSIGNAL
constexpr int SEND_FLAGS = MSG_NOSIGNAL;
#else
constexpr int SEND_FLAGS = 0;
#endif
int bio_send(void* context, const unsigned char* data, size_t size) {
const int fd = *static_cast<int*>(context);
const ssize_t sent = send(fd, data, size, SEND_FLAGS);
if (sent >= 0) {
return static_cast<int>(sent);
}
if (errno == EINTR) {
return MBEDTLS_ERR_SSL_WANT_WRITE;
}
return (errno == EAGAIN || errno == EWOULDBLOCK) ? MBEDTLS_ERR_SSL_TIMEOUT : MBEDTLS_ERR_SSL_INTERNAL_ERROR;
}
int bio_receive(void* context, unsigned char* data, size_t size) {
const int fd = *static_cast<int*>(context);
const ssize_t received = recv(fd, data, size, 0);
if (received >= 0) {
return static_cast<int>(received);
}
if (errno == EINTR) {
return MBEDTLS_ERR_SSL_WANT_READ;
}
// The socket's receive timeout elapsed
return (errno == EAGAIN || errno == EWOULDBLOCK) ? MBEDTLS_ERR_SSL_TIMEOUT : MBEDTLS_ERR_SSL_INTERNAL_ERROR;
}
// RAII: the TLS session, the socket and the certificate are released on every exit path
class TlsConnection {
int fd = -1;
bool handshakeDone = false;
mbedtls_x509_crt ca {};
mbedtls_ssl_config config {};
mbedtls_ssl_context ssl {};
// Bytes that readLine() read beyond the line it returned
std::string pending;
public:
TlsConnection() {
mbedtls_x509_crt_init(&ca);
mbedtls_ssl_config_init(&config);
mbedtls_ssl_init(&ssl);
}
~TlsConnection() {
if (handshakeDone) {
mbedtls_ssl_close_notify(&ssl);
}
if (fd >= 0) {
close(fd);
}
mbedtls_ssl_free(&ssl);
mbedtls_ssl_config_free(&config);
mbedtls_x509_crt_free(&ca);
}
bool loadCertificate(const std::string& certPath) {
auto* file = fopen(certPath.c_str(), "rb");
if (file == nullptr) {
return false;
}
fseek(file, 0, SEEK_END);
const long size = ftell(file);
fseek(file, 0, SEEK_SET);
if (size <= 0) {
fclose(file);
return false;
}
// A PEM certificate is parsed as a NUL-terminated string
std::string certificate(static_cast<size_t>(size) + 1, '\0');
const size_t readBytes = fread(certificate.data(), 1, static_cast<size_t>(size), file);
fclose(file);
if (readBytes != static_cast<size_t>(size)) {
return false;
}
return mbedtls_x509_crt_parse(&ca, reinterpret_cast<const unsigned char*>(certificate.data()), certificate.size()) == 0;
}
bool connectSocket(const HttpUrl& url) {
addrinfo hints {};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
addrinfo* addresses = nullptr;
if (getaddrinfo(url.host.c_str(), url.port.c_str(), &hints, &addresses) != 0) {
LOG_E(TAG, "Failed to resolve %s", url.host.c_str());
return false;
}
const timeval timeout { .tv_sec = TIMEOUT_SECONDS, .tv_usec = 0 };
for (auto* address = addresses; address != nullptr; address = address->ai_next) {
fd = socket(address->ai_family, address->ai_socktype, address->ai_protocol);
if (fd < 0) {
continue;
}
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
#ifdef SO_NOSIGPIPE
const int enabled = 1;
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled));
#endif
int result;
do {
result = ::connect(fd, address->ai_addr, address->ai_addrlen);
} while (result != 0 && errno == EINTR);
if (result == 0) {
break;
}
close(fd);
fd = -1;
}
freeaddrinfo(addresses);
return fd >= 0;
}
bool handshake(const HttpUrl& url, HttpDownloadLink* link) {
if (mbedtls_ssl_config_defaults(&config, MBEDTLS_SSL_IS_CLIENT, MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT) != 0) {
return false;
}
mbedtls_ssl_conf_authmode(&config, MBEDTLS_SSL_VERIFY_REQUIRED);
mbedtls_ssl_conf_ca_chain(&config, &ca, nullptr);
// Same as the ESP32 implementation
mbedtls_ssl_conf_max_tls_version(&config, MBEDTLS_SSL_VERSION_TLS1_2);
if (mbedtls_ssl_setup(&ssl, &config) != 0 || mbedtls_ssl_set_hostname(&ssl, url.host.c_str()) != 0) {
return false;
}
mbedtls_ssl_set_bio(&ssl, &fd, bio_send, bio_receive, nullptr);
int result;
while ((result = mbedtls_ssl_handshake(&ssl)) != 0) {
if ((result != MBEDTLS_ERR_SSL_WANT_READ && result != MBEDTLS_ERR_SSL_WANT_WRITE) || http_download_is_cancelled(link)) {
LOG_E(TAG, "TLS handshake with %s failed: -0x%04x", url.host.c_str(), static_cast<unsigned>(-result));
return false;
}
}
handshakeDone = true;
return true;
}
bool writeAll(const std::string& data) {
size_t offset = 0;
while (offset < data.size()) {
const int written = mbedtls_ssl_write(&ssl, reinterpret_cast<const unsigned char*>(data.data()) + offset, data.size() - offset);
if (written > 0) {
offset += static_cast<size_t>(written);
} else if (written != MBEDTLS_ERR_SSL_WANT_READ && written != MBEDTLS_ERR_SSL_WANT_WRITE) {
return false;
}
}
return true;
}
/** @return the number of bytes read, 0 when the server closed the connection, or -1 on an error */
int read(char* buffer, size_t size) {
if (!pending.empty()) {
const size_t count = std::min(size, pending.size());
memcpy(buffer, pending.data(), count);
pending.erase(0, count);
return static_cast<int>(count);
}
while (true) {
const int result = mbedtls_ssl_read(&ssl, reinterpret_cast<unsigned char*>(buffer), size);
if (result >= 0) {
return result;
}
if (result == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY) {
return 0;
}
if (result != MBEDTLS_ERR_SSL_WANT_READ && result != MBEDTLS_ERR_SSL_WANT_WRITE) {
return -1;
}
}
}
/** Reads a line that ends with CRLF, without the CRLF */
bool readLine(std::string& line) {
constexpr size_t MAX_LINE_LENGTH = 8192;
while (true) {
const auto end = pending.find("\r\n");
if (end != std::string::npos) {
line = pending.substr(0, end);
pending.erase(0, end + 2);
return true;
}
if (pending.size() > MAX_LINE_LENGTH) {
return false;
}
char buffer[BUFFER_SIZE];
const int result = mbedtls_ssl_read(&ssl, reinterpret_cast<unsigned char*>(buffer), sizeof(buffer));
if (result > 0) {
pending.append(buffer, static_cast<size_t>(result));
} else if (result != MBEDTLS_ERR_SSL_WANT_READ && result != MBEDTLS_ERR_SSL_WANT_WRITE) {
return false;
}
}
}
};
// RAII: a file that's removed unless commit() renamed it onto the target path
class TempFile {
std::string tempPath;
FILE* file = nullptr;
public:
explicit TempFile(const std::string& targetPath) : tempPath(targetPath + ".tmp") {
file = fopen(tempPath.c_str(), "wb");
}
~TempFile() {
if (file != nullptr) {
fclose(file);
remove(tempPath.c_str());
}
}
bool isOpen() const { return file != nullptr; }
bool write(const char* data, size_t size) { return fwrite(data, 1, size, file) == size; }
/** Closes the file and replaces the target path with it */
bool commit(const std::string& targetPath) {
const bool closed = fclose(file) == 0;
file = nullptr;
if (!closed || rename(tempPath.c_str(), targetPath.c_str()) != 0) {
remove(tempPath.c_str());
return false;
}
return true;
}
};
}
HttpDownloadEvent http_download_run(const std::string& url, const std::string& certPath, const std::string& targetPath, HttpDownloadLink* link) {
if (http_download_is_cancelled(link)) {
return http_download_make_cancelled_event();
}
HttpUrl parsedUrl;
if (!http_parse_https_url(url, parsedUrl)) {
return http_download_make_error_event("Unsupported URL");
}
if (psa_crypto_init() != PSA_SUCCESS) {
return http_download_make_error_event("Failed to initialize crypto");
}
TlsConnection connection;
if (!connection.loadCertificate(certPath)) {
return http_download_make_error_event("Failed to read certificate file");
}
if (!connection.connectSocket(parsedUrl) || !connection.handshake(parsedUrl, link)) {
return http_download_make_error_event("Failed to open connection");
}
const std::string request =
"GET " + parsedUrl.path + " HTTP/1.1\r\n"
"Host: " + parsedUrl.host + "\r\n"
"User-Agent: Tactility\r\n"
"Accept-Encoding: identity\r\n"
"Connection: close\r\n"
"\r\n";
if (!connection.writeAll(request)) {
return http_download_make_error_event("Failed to send request");
}
std::string line;
int statusCode = 0;
if (!connection.readLine(line) || !http_parse_status_line(line, statusCode)) {
return http_download_make_error_event("Failed to fetch response headers");
}
// -1 means the length is unknown: the body is chunked or ends when the server closes the connection
long contentLength = -1;
std::string contentLengthValue;
bool hasContentLength = false;
bool hasConflictingContentLengths = false;
bool chunked = false;
while (true) {
if (!connection.readLine(line)) {
return http_download_make_error_event("Failed to fetch response headers", statusCode);
}
if (line.empty()) {
break;
}
std::string name;
std::string value;
if (!http_parse_header_line(line, name, value)) {
continue;
}
if (name == "content-length") {
// Repeated headers must have the same value, or the body's end is ambiguous
if (hasContentLength && value != contentLengthValue) {
hasConflictingContentLengths = true;
}
contentLengthValue = value;
hasContentLength = true;
} else if (name == "transfer-encoding" && value.find("chunked") != std::string::npos) {
chunked = true;
}
}
if (statusCode < 200 || statusCode >= 300) {
return http_download_make_error_event("Server response is not OK", statusCode);
}
// A chunked body ignores the Content-Length
if (hasContentLength && !chunked && (hasConflictingContentLengths || !http_parse_content_length(contentLengthValue, contentLength))) {
return http_download_make_error_event("Invalid Content-Length", statusCode);
}
if (http_download_is_cancelled(link)) {
return http_download_make_cancelled_event(statusCode);
}
// Downloaded to a sibling ".tmp" file first and only renamed onto targetPath on success, so a
// failed/cancelled download never leaves a partial file at targetPath.
TempFile file(targetPath);
if (!file.isOpen()) {
return http_download_make_error_event("Failed to open target file", statusCode);
}
char buffer[BUFFER_SIZE];
bool cancelled = false;
// Copies up to `count` body bytes to the file. A count of -1 copies until the server closes the connection.
// @return an error message, or nullptr when the bytes were copied or the download was cancelled
auto copyBody = [&](long count) -> const char* {
while (count != 0) {
if (http_download_is_cancelled(link)) {
cancelled = true;
return nullptr;
}
const size_t wanted = (count < 0) ? sizeof(buffer) : std::min(sizeof(buffer), static_cast<size_t>(count));
const int received = connection.read(buffer, wanted);
if (received < 0) {
return "Failed to read response data";
}
if (received == 0) {
return (count < 0) ? nullptr : "Response body was incomplete";
}
if (!file.write(buffer, static_cast<size_t>(received))) {
return "Failed to write downloaded data";
}
if (count > 0) {
count -= received;
}
}
return nullptr;
};
const char* error = nullptr;
if (chunked) {
while (error == nullptr && !cancelled) {
size_t chunkSize = 0;
if (!connection.readLine(line) || !http_parse_chunk_size(line, chunkSize)) {
error = "Failed to read response data";
} else if (chunkSize == 0) {
// The trailer ends with an empty line
while (connection.readLine(line) && !line.empty()) {}
break;
} else if ((error = copyBody(static_cast<long>(chunkSize))) == nullptr && (!connection.readLine(line) || !line.empty())) {
error = "Failed to read response data";
}
}
} else {
error = copyBody(contentLength);
}
if (cancelled) {
return http_download_make_cancelled_event(statusCode);
}
if (error != nullptr) {
return http_download_make_error_event(error, statusCode);
}
if (!file.commit(targetPath)) {
return http_download_make_error_event("Failed to finalize downloaded file", statusCode);
}
return http_download_make_success_event(statusCode);
}