Skip to content

Commit fd0ab12

Browse files
committed
[curl] Add SendPutReq to RCurlConnection with tests
1 parent 33e2f9c commit fd0ab12

4 files changed

Lines changed: 241 additions & 0 deletions

File tree

net/curl/inc/ROOT/RCurlConnection.hxx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ public:
117117
/// a valid batching of requests into multiple multi-range requests takes place automatically.
118118
/// The fNBytesRecv member of the ranges is only well-defined on success.
119119
RStatus SendRangesReq(std::size_t N, RUserRange *ranges);
120+
/// Uploads data to the URL using an HTTP PUT request.
121+
RStatus SendPutReq(const unsigned char *data, std::size_t length);
120122

121123
const std::string &GetEscapedUrl() const { return fEscapedUrl; }
122124

net/curl/src/RCurlConnection.cxx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,50 @@ void ReverseDisplacements(std::vector<std::size_t> &displacements, ROOT::Interna
552552
}
553553
}
554554

555+
/// State for the PUT upload read callback: tracks progress through the upload buffer.
556+
struct RUploadState {
557+
const unsigned char *fData = nullptr;
558+
std::size_t fLength = 0;
559+
std::size_t fOffset = 0;
560+
};
561+
562+
/// CURLOPT_READFUNCTION callback for PUT uploads. Copies `requested` bytes from the upload
563+
/// buffer into `buffer` and advances the offset. Returns the number of bytes copied, or 0 at
564+
/// end-of-data to signal that the upload is complete. Since CURLOPT_INFILESIZE_LARGE is set,
565+
/// curl knows the total size and will never request more bytes than remain.
566+
std::size_t CallbackPutRead(char *buffer, std::size_t size, std::size_t nmemb, void *userdata)
567+
{
568+
auto *state = static_cast<RUploadState *>(userdata);
569+
R__ASSERT(state->fOffset <= state->fLength);
570+
571+
std::size_t remaining = state->fLength - state->fOffset;
572+
if (remaining == 0)
573+
return 0;
574+
575+
std::size_t requested = size * nmemb;
576+
// CURL_READFUNC_ABORT (0x10000000) collides with a valid byte count at 256 MiB;
577+
// assert that curl never asks for that much in a single callback invocation.
578+
R__ASSERT(requested < CURL_READFUNC_ABORT);
579+
R__ASSERT(requested <= remaining);
580+
memcpy(buffer, state->fData + state->fOffset, requested);
581+
state->fOffset += requested;
582+
return requested;
583+
}
584+
585+
/// CURLOPT_SEEKFUNCTION callback for PUT uploads. Required because CURLOPT_FOLLOWLOCATION
586+
/// is enabled: on a redirect curl needs to rewind the upload data before resending.
587+
int CallbackPutSeek(void *userdata, curl_off_t offset, int origin)
588+
{
589+
auto *state = static_cast<RUploadState *>(userdata);
590+
// curl documents that it will only use SEEK_SET; guard against anything else defensively.
591+
if (origin != SEEK_SET)
592+
return CURL_SEEKFUNC_CANTSEEK;
593+
if (offset < 0 || static_cast<std::size_t>(offset) > state->fLength)
594+
return CURL_SEEKFUNC_FAIL;
595+
state->fOffset = static_cast<std::size_t>(offset);
596+
return CURL_SEEKFUNC_OK;
597+
}
598+
555599
/// Wrapper around curl_easy_setopt that asserts on failure. Most option-setting calls in this
556600
/// file use valid options and values by construction, so failure indicates a programming error.
557601
template <typename T>
@@ -854,6 +898,26 @@ ROOT::Internal::RCurlConnection::SendRangesReq(std::size_t N, RUserRange *ranges
854898
return status;
855899
}
856900

901+
ROOT::Internal::RCurlConnection::RStatus
902+
ROOT::Internal::RCurlConnection::SendPutReq(const unsigned char *data, std::size_t length)
903+
{
904+
ResetHandle();
905+
906+
SetCurlOption(fHandle, CURLOPT_UPLOAD, 1L);
907+
SetCurlOption(fHandle, CURLOPT_INFILESIZE_LARGE, static_cast<curl_off_t>(length));
908+
909+
RUploadState uploadState{data, length, 0};
910+
SetCurlOption(fHandle, CURLOPT_READFUNCTION, CallbackPutRead);
911+
SetCurlOption(fHandle, CURLOPT_READDATA, &uploadState);
912+
SetCurlOption(fHandle, CURLOPT_SEEKFUNCTION, CallbackPutSeek);
913+
SetCurlOption(fHandle, CURLOPT_SEEKDATA, &uploadState);
914+
915+
RStatus status;
916+
Perform(status);
917+
918+
return status;
919+
}
920+
857921
void ROOT::Internal::RCurlConnection::SetCredentials(const RS3Credentials &credentials)
858922
{
859923
ClearCredentials();

net/curl/test/curl_connection.cxx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,67 @@
44

55
#include "TServerSocket.h"
66

7+
#include <algorithm>
8+
#include <cctype>
79
#include <cstdint>
810
#include <cstring>
911
#include <string>
1012
#include <thread>
13+
#include <vector>
14+
15+
/// Return a lower-cased copy of the input string.
16+
static std::string ToLower(std::string s)
17+
{
18+
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); });
19+
return s;
20+
}
21+
22+
/// Accept a PUT request: read headers + body, optionally respond to Expect: 100-continue, send 200 OK.
23+
static void TaskRecvPut(TServerSocket *serverSocket, std::string *requestHeaders, std::string *requestBody)
24+
{
25+
requestHeaders->clear();
26+
requestBody->clear();
27+
auto sock = serverSocket->Accept();
28+
29+
const char *eof = "\r\n\r\n";
30+
const std::size_t eofLen = strlen(eof);
31+
std::size_t nextInEof = 0;
32+
char c;
33+
while (sock->RecvRaw(&c, 1)) {
34+
requestHeaders->push_back(c);
35+
if (c == eof[nextInEof]) {
36+
if (++nextInEof == eofLen)
37+
break;
38+
} else {
39+
nextInEof = 0;
40+
}
41+
}
42+
43+
// If the client sent Expect: 100-continue, respond with HTTP 100 before reading the body
44+
std::string headersLower = ToLower(*requestHeaders);
45+
if (headersLower.find("expect: 100-continue") != std::string::npos) {
46+
const char *continueResponse = "HTTP/1.1 100 Continue\r\n\r\n";
47+
sock->SendRaw(continueResponse, strlen(continueResponse));
48+
}
49+
50+
// Parse content-length (case-insensitive)
51+
std::size_t contentLength = 0;
52+
auto pos = headersLower.find("content-length: ");
53+
if (pos != std::string::npos) {
54+
auto valStart = pos + strlen("content-length: ");
55+
auto valEnd = headersLower.find("\r\n", valStart);
56+
contentLength = std::stoul(headersLower.substr(valStart, valEnd - valStart));
57+
}
58+
59+
if (contentLength > 0) {
60+
requestBody->resize(contentLength);
61+
sock->RecvRaw(&(*requestBody)[0], contentLength);
62+
}
63+
64+
const char *response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
65+
sock->SendRaw(response, strlen(response));
66+
sock->Close();
67+
}
1168

1269
static void TaskRecv(TServerSocket *serverSocket, std::string *request)
1370
{
@@ -63,3 +120,64 @@ TEST(RCurlConnection, Cred)
63120
threadRecv.join();
64121
EXPECT_EQ(std::string::npos, request.find("\r\nAuthorization: "));
65122
}
123+
124+
TEST(RCurlConnection, Put)
125+
{
126+
TServerSocket sock(0, false, TServerSocket::kDefaultBacklog, -1, ESocketBindOption::kInaddrLoopback);
127+
const std::string url =
128+
std::string("http://") + sock.GetLocalInetAddress().GetHostAddress() + ":" + std::to_string(sock.GetLocalPort());
129+
130+
const unsigned char payload[] = "Hello, S3!";
131+
const std::size_t payloadLen = sizeof(payload) - 1; // exclude null terminator
132+
133+
std::string headers;
134+
std::string body;
135+
std::thread threadRecv(TaskRecvPut, &sock, &headers, &body);
136+
137+
ROOT::Internal::RCurlConnection conn(url);
138+
auto status = conn.SendPutReq(payload, payloadLen);
139+
140+
threadRecv.join();
141+
EXPECT_TRUE(static_cast<bool>(status));
142+
EXPECT_EQ(0u, headers.find("PUT "));
143+
144+
// Normalize headers to lower-case for case-insensitive matching
145+
std::string headersLower = ToLower(headers);
146+
auto clPos = headersLower.find("content-length: " + std::to_string(payloadLen));
147+
ASSERT_NE(std::string::npos, clPos) << "content-length header not found in request";
148+
149+
EXPECT_EQ(std::string(reinterpret_cast<const char *>(payload), payloadLen), body);
150+
}
151+
152+
/// PUT with a payload larger than libcurl's internal Expect: 100-continue threshold (1 MB since curl 7.69).
153+
/// Verifies that the server-side 100 Continue handshake works and all bytes arrive correctly.
154+
TEST(RCurlConnection, PutLargeExpect100)
155+
{
156+
TServerSocket sock(0, false, TServerSocket::kDefaultBacklog, -1, ESocketBindOption::kInaddrLoopback);
157+
const std::string url =
158+
std::string("http://") + sock.GetLocalInetAddress().GetHostAddress() + ":" + std::to_string(sock.GetLocalPort());
159+
160+
// 2 MB payload with a known repeating pattern
161+
const std::size_t payloadLen = 2 * 1024 * 1024;
162+
std::vector<unsigned char> payload(payloadLen);
163+
for (std::size_t i = 0; i < payloadLen; ++i)
164+
payload[i] = static_cast<unsigned char>(i & 0xFF);
165+
166+
std::string headers;
167+
std::string body;
168+
std::thread threadRecv(TaskRecvPut, &sock, &headers, &body);
169+
170+
ROOT::Internal::RCurlConnection conn(url);
171+
auto status = conn.SendPutReq(payload.data(), payloadLen);
172+
173+
threadRecv.join();
174+
EXPECT_TRUE(static_cast<bool>(status));
175+
EXPECT_EQ(0u, headers.find("PUT "));
176+
177+
std::string headersLower = ToLower(headers);
178+
EXPECT_NE(std::string::npos, headersLower.find("expect: 100-continue"))
179+
<< "large upload should include Expect: 100-continue header";
180+
EXPECT_NE(std::string::npos, headersLower.find("content-length: " + std::to_string(payloadLen)));
181+
ASSERT_EQ(payloadLen, body.size());
182+
EXPECT_EQ(0, memcmp(body.data(), payload.data(), payloadLen));
183+
}

net/curl/test/curl_env.cxx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
#include "TCurlFile.h"
1212
#include "TSystem.h"
1313

14+
#include <cstring>
1415
#include <memory>
1516
#include <utility>
17+
#include <vector>
1618

1719
TEST(RCurlConnection, CredFromEnv)
1820
{
@@ -93,3 +95,58 @@ TEST(CurlFile, S3Credentials)
9395
gSystem->Unsetenv("S3_ACCESS_KEY");
9496
gSystem->Unsetenv("S3_SECRET_KEY");
9597
}
98+
99+
TEST(CurlFile, S3PutAndRead)
100+
{
101+
const auto testAccessKey = std::getenv("ROOT_TEST_S3_ACCESS_KEY");
102+
const auto testSecretKey = std::getenv("ROOT_TEST_S3_SECRET_KEY");
103+
if (!testAccessKey || testAccessKey[0] == '\0' || !testSecretKey || testSecretKey[0] == '\0') {
104+
GTEST_SKIP() << "Missing S3 test credentials <ROOT_TEST_S3_[ACCESS|SECRET]_KEY>, skipping";
105+
}
106+
if (ROOT::Internal::RCurlConnection::GetCurlVersion() <= 0x078100) {
107+
GTEST_SKIP() << "libcurl <= 7.81 is known to produce an AWSv4 signature incompatible with Ceph S3";
108+
}
109+
110+
const std::string url = "https://root-project-s3test.s3.cern.ch/test-curl-put-roundtrip.bin";
111+
112+
ROOT::Internal::RS3Credentials creds;
113+
creds.fAccessKey = testAccessKey;
114+
creds.fSecretKey = testSecretKey;
115+
116+
// PUT a known payload
117+
const unsigned char payload[] = "RCurlConnection::SendPutReq round-trip test";
118+
const std::size_t payloadLen = sizeof(payload) - 1;
119+
120+
{
121+
ROOT::Internal::RCurlConnection conn(url);
122+
conn.SetCredentials(creds);
123+
auto status = conn.SendPutReq(payload, payloadLen);
124+
ASSERT_TRUE(static_cast<bool>(status)) << "PUT failed: " << status.fStatusMsg;
125+
}
126+
127+
// HEAD to verify size
128+
{
129+
ROOT::Internal::RCurlConnection conn(url);
130+
conn.SetCredentials(creds);
131+
std::uint64_t remoteSize = 0;
132+
auto status = conn.SendHeadReq(remoteSize);
133+
ASSERT_TRUE(static_cast<bool>(status)) << "HEAD failed: " << status.fStatusMsg;
134+
EXPECT_EQ(static_cast<std::uint64_t>(payloadLen), remoteSize);
135+
}
136+
137+
// GET (range read) to verify content
138+
{
139+
std::vector<unsigned char> readback(payloadLen, 0);
140+
ROOT::Internal::RCurlConnection::RUserRange range;
141+
range.fDestination = readback.data();
142+
range.fOffset = 0;
143+
range.fLength = payloadLen;
144+
145+
ROOT::Internal::RCurlConnection conn(url);
146+
conn.SetCredentials(creds);
147+
auto status = conn.SendRangesReq(1, &range);
148+
ASSERT_TRUE(static_cast<bool>(status)) << "GET failed: " << status.fStatusMsg;
149+
EXPECT_EQ(payloadLen, range.fNBytesRecv);
150+
EXPECT_EQ(0, memcmp(readback.data(), payload, payloadLen));
151+
}
152+
}

0 commit comments

Comments
 (0)