Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .xlings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"workspace": {
"mcpp": { "linux": "0.0.13" }
"mcpp": "0.0.87"
}
}
9 changes: 5 additions & 4 deletions mcpp.lock
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Auto-generated by mcpp. Do not edit by hand.
version = 1
version = 2

[package."mbedtls"]
[package."compat.mbedtls"]
namespace = "compat"
version = "3.6.1"
source = "mcpp-index+https://github.com/mcpp-community/mcpp-index.git"
hash = "sha256:<from-xlings>"
source = "index+compat@3.6.1"
hash = "fnv1a:83dea67ac1379be3"

2 changes: 1 addition & 1 deletion mcpp.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
namespace = "mcpplibs"
name = "tinyhttps"
version = "0.2.8"
version = "0.2.9"
description = "Minimal C++23 HTTP/HTTPS client with SSE streaming support"
license = "Apache-2.0"
repo = "https://github.com/mcpplibs/tinyhttps"
Expand Down
97 changes: 93 additions & 4 deletions src/http.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export struct DownloadToFileResult {
int statusCode { 0 };
std::string error;
std::int64_t bytesWritten { 0 };
std::optional<std::int64_t> expectedBytes;
std::string finalUrl;
std::string etag;
std::string lastModified;
bool ok() const { return statusCode >= 200 && statusCode < 300 && error.empty(); }
};

Expand Down Expand Up @@ -186,6 +190,28 @@ static std::string read_line(TlsSocket& sock, int timeoutMs) {
return line;
}

static std::expected<std::string, std::string>
read_complete_line(TlsSocket& sock, int timeoutMs) {
std::string line;
char c {};
while (true) {
if (!sock.wait_readable(timeoutMs)) {
return std::unexpected("timeout or EOF before CRLF");
}
int ret = sock.read(&c, 1);
if (ret <= 0) {
return std::unexpected("EOF before CRLF");
}
line += c;
if (line.size() >= 2
&& line[line.size() - 2] == '\r'
&& line[line.size() - 1] == '\n') {
line.resize(line.size() - 2);
return line;
}
}
}

// Write all data to socket
static bool write_all(TlsSocket& sock, const std::string& data) {
int total = 0;
Expand Down Expand Up @@ -216,6 +242,20 @@ static int parse_hex(std::string_view s) {
return result;
}

export std::optional<std::int64_t>
parse_chunk_size_line(std::string_view line) {
if (line.empty()) return std::nullopt;
std::uint64_t value {};
auto [end, error] = std::from_chars(
line.data(), line.data() + line.size(), value, 16);
if (error != std::errc{} || end != line.data() + line.size()
|| value > static_cast<std::uint64_t>(
std::numeric_limits<int>::max())) {
return std::nullopt;
}
return static_cast<std::int64_t>(value);
}

// Case-insensitive string comparison
static bool iequals(std::string_view a, std::string_view b) {
if (a.size() != b.size()) return false;
Expand Down Expand Up @@ -870,6 +910,8 @@ private:
std::int64_t contentLength = -1;
bool connectionClose = false;
std::string location;
std::string etag;
std::string lastModified;

while (true) {
std::string line = read_line(*sock, config_.readTimeoutMs);
Expand All @@ -894,6 +936,10 @@ private:
connectionClose = true;
if (iequals(key, "Location"))
location = valStr;
if (iequals(key, "ETag"))
etag = valStr;
if (iequals(key, "Last-Modified"))
lastModified = valStr;
}

// Follow redirects
Expand All @@ -920,6 +966,11 @@ private:
return result;
}

result.finalUrl = url;
result.etag = std::move(etag);
result.lastModified = std::move(lastModified);
if (contentLength >= 0) result.expectedBytes = contentLength;

// Open output file
std::error_code ec;
std::filesystem::create_directories(destFile.parent_path(), ec);
Expand Down Expand Up @@ -950,15 +1001,45 @@ private:
if (chunked) {
while (true) {
if (cancelled()) return result;
std::string sizeLine = read_line(*sock, config_.readTimeoutMs);
auto sizeResult = read_complete_line(
*sock, config_.readTimeoutMs);
if (!sizeResult) {
result.error = "Invalid chunk size line: "
+ std::move(sizeResult).error();
result.bytesWritten = downloaded;
sock->close();
pool_.erase(poolKey);
return result;
}
std::string sizeLine = std::move(*sizeResult);
auto semi = sizeLine.find(';');
if (semi != std::string::npos) sizeLine = sizeLine.substr(0, semi);
while (!sizeLine.empty() && (sizeLine.back() == ' ' || sizeLine.back() == '\t'))
sizeLine.pop_back();

int chunkSize = parse_hex(sizeLine);
auto parsedChunkSize = parse_chunk_size_line(sizeLine);
if (!parsedChunkSize) {
result.error = "Invalid chunk size: " + sizeLine;
result.bytesWritten = downloaded;
sock->close();
pool_.erase(poolKey);
return result;
}
int chunkSize = static_cast<int>(*parsedChunkSize);
if (chunkSize == 0) {
read_line(*sock, config_.readTimeoutMs);
for (;;) {
auto trailer = read_complete_line(
*sock, config_.readTimeoutMs);
if (!trailer) {
result.error = "Invalid chunk trailer: "
+ std::move(trailer).error();
result.bytesWritten = downloaded;
sock->close();
pool_.erase(poolKey);
return result;
}
if (trailer->empty()) break;
}
break;
}

Expand All @@ -979,7 +1060,15 @@ private:
remaining -= toRead;
if (onProgress) onProgress(totalBytes, downloaded);
}
read_line(*sock, config_.readTimeoutMs);
auto delimiter = read_complete_line(
*sock, config_.readTimeoutMs);
if (!delimiter || !delimiter->empty()) {
result.error = "Missing CRLF after chunk data";
result.bytesWritten = downloaded;
sock->close();
pool_.erase(poolKey);
return result;
}
}
} else if (contentLength > 0) {
char buf[8192];
Expand Down
30 changes: 30 additions & 0 deletions tests/test_download.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,36 @@ import std;

namespace https = mcpplibs::tinyhttps;

TEST(ChunkedProtocol, RejectsEmptyInvalidAndOverflowSizeLines) {
EXPECT_FALSE(https::parse_chunk_size_line("").has_value());
EXPECT_FALSE(https::parse_chunk_size_line("xyz").has_value());
EXPECT_FALSE(https::parse_chunk_size_line("1g").has_value());
EXPECT_FALSE(https::parse_chunk_size_line("FFFFFFFFFFFFFFFF").has_value());
}

TEST(ChunkedProtocol, AcceptsValidSizeAndTerminalChunk) {
ASSERT_TRUE(https::parse_chunk_size_line("1a").has_value());
EXPECT_EQ(*https::parse_chunk_size_line("1a"), 26);
ASSERT_TRUE(https::parse_chunk_size_line("0").has_value());
EXPECT_EQ(*https::parse_chunk_size_line("0"), 0);
}

TEST(DownloadResultContract, CarriesTransferAndResponseMetadata) {
https::DownloadToFileResult result;
result.bytesWritten = 42;
result.expectedBytes = 42;
result.finalUrl = "https://example.test/final";
result.etag = "\"abc\"";
result.lastModified = "Wed, 21 Oct 2015 07:28:00 GMT";

EXPECT_EQ(result.bytesWritten, 42);
ASSERT_TRUE(result.expectedBytes.has_value());
EXPECT_EQ(*result.expectedBytes, 42);
EXPECT_EQ(result.finalUrl, "https://example.test/final");
EXPECT_EQ(result.etag, "\"abc\"");
EXPECT_FALSE(result.lastModified.empty());
}

// Test download_to_file against a real HTTPS endpoint.
// Uses httpbin.org which returns known-size responses.

Expand Down
Loading