Arachne 1.0
Arachne - the perpetual stitcher of Wikidata entities.
Loading...
Searching...
No Matches
transport.cpp
Go to the documentation of this file.
1#include "pheidippides/transport.hpp"
2
3#include "arachne/contracts.hpp"
4#include "arachne/crypto.hpp"
5
6#include <curl/curl.h>
7#include <nlohmann/json.hpp>
8
9#include <sys/stat.h>
10
11#include <algorithm>
12#include <array>
13#include <atomic>
14#include <cctype>
15#include <cerrno>
16#include <chrono>
17#include <cstring>
18#include <ctime>
19#include <fcntl.h>
20#include <iomanip>
21#include <iterator>
22#include <limits>
23#include <memory>
24#include <mutex>
25#include <optional>
26#include <sstream>
27#include <stdexcept>
28#include <string_view>
29#include <system_error>
30#include <thread>
31#include <unistd.h>
32#include <utility>
33
34namespace arachne::pheidippides {
35namespace {
36
37 constexpr std::size_t maximum_header_bytes = 1024U * 1024U;
38 constexpr std::size_t maximum_response_headers = 1'024U;
39 constexpr std::size_t maximum_redirects = 20U;
40 constexpr std::size_t maximum_transport_attempts = 20U;
41 constexpr std::size_t maximum_url_bytes = 64U * 1024U;
42 constexpr std::size_t maximum_request_id_bytes = 128U;
43
44 std::once_flag curl_global_once;
45 std::recursive_mutex curl_share_mutex;
46 CURLSH* curl_connection_share = nullptr;
47 std::atomic<std::uint64_t> stage_sequence { 0 };
48
50 CURL*, const curl_lock_data, const curl_lock_access, void*
51 ) noexcept {
52 curl_share_mutex.lock();
53 }
54
55 void curl_share_unlock(CURL*, const curl_lock_data, void*) noexcept {
56 curl_share_mutex.unlock();
57 }
58
60 void operator()(CURL* handle) const noexcept {
61 curl_easy_cleanup(handle);
62 }
63 };
64
66 void operator()(CURLU* handle) const noexcept {
67 curl_url_cleanup(handle);
68 }
69 };
70
72 void operator()(curl_slist* headers) const noexcept {
73 curl_slist_free_all(headers);
74 }
75 };
76
77 using easy_handle = std::unique_ptr<CURL, curl_easy_deleter>;
78 using url_handle = std::unique_ptr<CURLU, curl_url_deleter>;
79 using header_list = std::unique_ptr<curl_slist, curl_slist_deleter>;
80
82 std::call_once(curl_global_once, [] {
83 const CURLcode result = curl_global_init(CURL_GLOBAL_DEFAULT);
84 if (result != CURLE_OK) {
85 throw std::runtime_error("curl_global_init failed");
86 }
87 curl_connection_share = curl_share_init();
88 if (curl_connection_share == nullptr
89 || curl_share_setopt(
90 curl_connection_share, CURLSHOPT_LOCKFUNC,
92 ) != CURLSHE_OK
93 || curl_share_setopt(
94 curl_connection_share, CURLSHOPT_UNLOCKFUNC,
96 ) != CURLSHE_OK
97 || curl_share_setopt(
98 curl_connection_share, CURLSHOPT_SHARE,
99 CURL_LOCK_DATA_DNS
100 ) != CURLSHE_OK
101 || curl_share_setopt(
102 curl_connection_share, CURLSHOPT_SHARE,
103 CURL_LOCK_DATA_CONNECT
104 ) != CURLSHE_OK) {
105 throw std::runtime_error(
106 "cannot initialize the Pheidippides connection pool"
107 );
108 }
109 });
110 }
111
112 [[nodiscard]] bool is_control(const char character) noexcept {
113 const auto value = static_cast<unsigned char>(character);
114 return value < 0x20U || value == 0x7fU;
115 }
116
117 [[nodiscard]] bool valid_request_id(const std::string_view request_id) {
118 if (request_id.empty() || request_id.size() > maximum_request_id_bytes
119 || std::isalnum(static_cast<unsigned char>(request_id.front()))
120 == 0) {
121 return false;
122 }
123 return std::ranges::all_of(request_id.substr(1), [](const char value) {
124 return std::isalnum(static_cast<unsigned char>(value)) != 0
125 || value == '.' || value == '_' || value == ':' || value == '-';
126 });
127 }
128
129 [[nodiscard]] std::string
130 artifact_id_for(const std::string_view request_id) {
131 constexpr std::string_view suffix = ".artifact";
132 if (request_id.size() + suffix.size() <= maximum_request_id_bytes) {
133 return std::string(request_id) + std::string(suffix);
134 }
135 return "artifact-" + crypto::sha256(request_id).substr(0, 32U);
136 }
137
138 [[nodiscard]] bool valid_header_name(const std::string_view name) {
139 if (name.empty()) {
140 return false;
141 }
142 constexpr std::string_view separators = "()<>@,;:\\\"/[]?={} \t";
143 return std::ranges::none_of(name, [&](const char character) {
144 return is_control(character)
145 || separators.find(character) != std::string_view::npos;
146 });
147 }
148
149 [[nodiscard]] std::string ascii_lower(std::string value) {
150 std::ranges::transform(value, value.begin(), [](const char character) {
151 return static_cast<char>(
152 std::tolower(static_cast<unsigned char>(character))
153 );
154 });
155 return value;
156 }
157
158 [[nodiscard]] bool valid_header(const http_header& header) {
159 return valid_header_name(header.name)
160 && ascii_lower(header.name) != "host"
161 && std::ranges::none_of(header.value, [](const char character) {
162 return character == '\0' || character == '\r'
163 || character == '\n';
164 });
165 }
166
167 [[nodiscard]] std::optional<std::string>
168 normalize_host(const std::string_view input) {
169 if (input.empty() || input.find('\0') != std::string_view::npos
170 || std::ranges::any_of(input, [](const char character) {
171 return std::isspace(static_cast<unsigned char>(character))
172 != 0;
173 })) {
174 return std::nullopt;
175 }
176 std::string host(input);
177 if (host.size() >= 2U && host.front() == '[' && host.back() == ']') {
178 host = host.substr(1U, host.size() - 2U);
179 }
180 if (!host.empty() && host.back() == '.') {
181 host.pop_back();
182 }
183 if (host.empty() || host.find_first_of("/@?#") != std::string::npos
184 || std::ranges::count(host, ':') == 1) {
185 return std::nullopt;
186 }
187 return ascii_lower(std::move(host));
188 }
189
190 struct parsed_url {
191 std::string normalized_url;
192 std::string scheme;
193 std::string host;
194 std::optional<unsigned> port;
195 };
196
197 [[nodiscard]] std::optional<unsigned>
198 parse_port(const std::string_view text) noexcept {
199 if (text.empty()
200 || !std::ranges::all_of(text, [](const char character) {
201 return character >= '0' && character <= '9';
202 })) {
203 return std::nullopt;
204 }
205 unsigned value = 0;
206 for (const char character : text) {
207 value = value * 10U
208 + static_cast<unsigned>(character - static_cast<char>('0'));
209 if (value > 65'535U) {
210 return std::nullopt;
211 }
212 }
213 return value == 0U ? std::nullopt : std::optional<unsigned> { value };
214 }
215
216 [[nodiscard]] std::optional<parsed_url>
217 parse_url(const std::string& value, std::string& error) {
218 if (value.empty() || value.size() > maximum_url_bytes
219 || value.find('\0') != std::string::npos) {
220 error = "URL is empty, contains NUL, or exceeds the length limit";
221 return std::nullopt;
222 }
223
224 url_handle parsed(curl_url());
225 if (!parsed
226 || curl_url_set(parsed.get(), CURLUPART_URL, value.c_str(), 0)
227 != CURLUE_OK) {
228 error = "URL is not an absolute HTTP or HTTPS URL";
229 return std::nullopt;
230 }
231
232 auto get_part
233 = [&](const CURLUPart part) -> std::optional<std::string> {
234 char* raw = nullptr;
235 const CURLUcode code
236 = curl_url_get(parsed.get(), part, &raw, CURLU_NO_DEFAULT_PORT);
237 if (code != CURLUE_OK || raw == nullptr) {
238 return std::nullopt;
239 }
240 std::unique_ptr<char, decltype(&curl_free)> owned(raw, &curl_free);
241 return std::string(raw);
242 };
243
244 auto scheme = get_part(CURLUPART_SCHEME);
245 auto host = get_part(CURLUPART_HOST);
246 auto port_text = get_part(CURLUPART_PORT);
247 auto normalized = get_part(CURLUPART_URL);
248 if (!scheme || !host || !normalized) {
249 error = "URL must include a scheme and host";
250 return std::nullopt;
251 }
252 *scheme = ascii_lower(std::move(*scheme));
253 if (*scheme != "http" && *scheme != "https") {
254 error = "only HTTP and HTTPS URLs are allowed";
255 return std::nullopt;
256 }
257 auto normalized_host = normalize_host(*host);
258 if (!normalized_host) {
259 error = "URL host is invalid";
260 return std::nullopt;
261 }
262 std::optional<unsigned> port;
263 if (port_text) {
264 port = parse_port(*port_text);
265 if (!port) {
266 error = "URL port is invalid";
267 return std::nullopt;
268 }
269 }
270 if (get_part(CURLUPART_USER) || get_part(CURLUPART_PASSWORD)) {
271 error = "URL credentials are not allowed";
272 return std::nullopt;
273 }
274 if (get_part(CURLUPART_FRAGMENT)) {
275 error = "URL fragments are not allowed";
276 return std::nullopt;
277 }
278 return parsed_url {
279 .normalized_url = std::move(*normalized),
280 .scheme = std::move(*scheme),
281 .host = std::move(*normalized_host),
282 .port = port,
283 };
284 }
285
287 std::string host;
288 std::optional<unsigned> port;
289 };
290
291 [[nodiscard]] std::optional<allowed_authority>
292 parse_allowed_authority(const std::string_view input) {
293 std::string_view host = input;
294 std::optional<unsigned> port;
295 if (input.starts_with('[')) {
296 const std::size_t closing = input.find(']');
297 if (closing == std::string_view::npos) {
298 return std::nullopt;
299 }
300 host = input.substr(0, closing + 1U);
301 if (closing + 1U != input.size()) {
302 if (input[closing + 1U] != ':') {
303 return std::nullopt;
304 }
305 port = parse_port(input.substr(closing + 2U));
306 if (!port) {
307 return std::nullopt;
308 }
309 }
310 } else if (std::ranges::count(input, ':') == 1) {
311 const std::size_t colon = input.find(':');
312 host = input.substr(0, colon);
313 port = parse_port(input.substr(colon + 1U));
314 if (!port) {
315 return std::nullopt;
316 }
317 }
318 auto normalized = normalize_host(host);
319 if (!normalized) {
320 return std::nullopt;
321 }
322 return allowed_authority {
323 .host = std::move(*normalized),
324 .port = port,
325 };
326 }
327
329 const parsed_url& url, const std::vector<std::string>& allowed_hosts,
330 std::string& error
331 ) {
332 if (allowed_hosts.empty()) {
333 error = "allowed_hosts must contain at least one exact host";
334 return false;
335 }
336 bool matched = false;
337 const unsigned effective_port
338 = url.port.value_or(url.scheme == "https" ? 443U : 80U);
339 for (const std::string& candidate : allowed_hosts) {
340 auto allowed = parse_allowed_authority(candidate);
341 if (!allowed) {
342 error = "allowed_hosts contains an invalid host";
343 return false;
344 }
345 if (allowed->host == url.host
346 && (!allowed->port || *allowed->port == effective_port)) {
347 matched = true;
348 }
349 }
350 if (matched) {
351 return true;
352 }
353 error = "URL host is not permitted by allowed_hosts";
354 return false;
355 }
356
357 [[nodiscard]] std::optional<std::string> resolve_redirect(
358 const std::string& base, const std::string& location, std::string& error
359 ) {
360 if (location.empty() || location.find('\0') != std::string::npos) {
361 error = "redirect Location is empty or invalid";
362 return std::nullopt;
363 }
364 url_handle resolved(curl_url());
365 if (!resolved
366 || curl_url_set(resolved.get(), CURLUPART_URL, base.c_str(), 0)
367 != CURLUE_OK
368 || curl_url_set(resolved.get(), CURLUPART_URL, location.c_str(), 0)
369 != CURLUE_OK) {
370 error = "redirect Location could not be resolved";
371 return std::nullopt;
372 }
373 char* raw = nullptr;
374 if (curl_url_get(
375 resolved.get(), CURLUPART_URL, &raw, CURLU_NO_DEFAULT_PORT
376 ) != CURLUE_OK
377 || raw == nullptr) {
378 error = "redirect URL could not be serialized";
379 return std::nullopt;
380 }
381 std::unique_ptr<char, decltype(&curl_free)> owned(raw, &curl_free);
382 return std::string(raw);
383 }
384
386 const std::filesystem::path& path, const std::filesystem::path& root
387 ) {
388 auto path_iterator = path.begin();
389 for (auto root_iterator = root.begin(); root_iterator != root.end();
390 ++root_iterator, ++path_iterator) {
391 if (path_iterator == path.end()
392 || *path_iterator != *root_iterator) {
393 return false;
394 }
395 }
396 return true;
397 }
398
400 const std::filesystem::path& root, const std::filesystem::path& target,
401 std::string& error
402 ) {
403 const std::filesystem::path parent = target.parent_path();
404 if (!path_begins_with(parent, root)) {
405 error = "artifact parent escapes the configured root";
406 return false;
407 }
408 const std::filesystem::path relative = parent.lexically_relative(root);
409 std::filesystem::path current = root;
410 for (const auto& component : relative) {
411 if (component == ".") {
412 continue;
413 }
414 current /= component;
415 std::error_code ec;
416 const auto state = std::filesystem::symlink_status(current, ec);
417 if (ec && ec != std::errc::no_such_file_or_directory) {
418 error = "cannot inspect artifact directory: " + ec.message();
419 return false;
420 }
421 if (!ec && std::filesystem::exists(state)) {
422 if (std::filesystem::is_symlink(state)
423 || !std::filesystem::is_directory(state)) {
424 error = "artifact directory component is not a real "
425 "directory";
426 return false;
427 }
428 continue;
429 }
430 ec.clear();
431 if (!std::filesystem::create_directory(current, ec) && ec) {
432 error = "cannot create artifact directory: " + ec.message();
433 return false;
434 }
435 }
436
437 std::error_code ec;
438 const auto canonical_parent
439 = std::filesystem::weakly_canonical(parent, ec);
440 if (ec || !path_begins_with(canonical_parent, root)) {
441 error = "artifact directory resolves outside the configured root";
442 return false;
443 }
444 return true;
445 }
446
448 const std::filesystem::path& root,
449 const std::filesystem::path& candidate, bool& unsafe, std::string& error
450 ) {
451 unsafe = false;
452 if (!path_begins_with(candidate, root)) {
453 unsafe = true;
454 error = "body artifact escapes the configured root";
455 return false;
456 }
457 const std::filesystem::path relative
458 = candidate.lexically_relative(root);
459 std::filesystem::path current = root;
460 std::size_t component_index = 0;
461 const std::size_t component_count = static_cast<std::size_t>(
462 std::distance(relative.begin(), relative.end())
463 );
464 for (const auto& component : relative) {
465 ++component_index;
466 current /= component;
467 std::error_code ec;
468 const auto state = std::filesystem::symlink_status(current, ec);
469 if (ec || !std::filesystem::exists(state)) {
470 error = "body artifact is missing or cannot be inspected";
471 return false;
472 }
473 if (std::filesystem::is_symlink(state)) {
474 unsafe = true;
475 error = "body artifact path contains a symlink";
476 return false;
477 }
478 const bool final_component = component_index == component_count;
479 if ((!final_component && !std::filesystem::is_directory(state))
480 || (final_component
481 && !std::filesystem::is_regular_file(state))) {
482 error = "body artifact path has an unexpected file type";
483 return false;
484 }
485 }
486 return component_count != 0U;
487 }
488
489 class verified_body_file final {
490 public:
492 verified_body_file(const verified_body_file&) = delete;
493 verified_body_file& operator=(const verified_body_file&) = delete;
494
495 verified_body_file(verified_body_file&& other) noexcept
496 : descriptor_(std::exchange(other.descriptor_, -1))
497 , length_(other.length_) { }
498
499 verified_body_file& operator=(verified_body_file&& other) noexcept {
500 if (this != &other) {
501 close();
502 descriptor_ = std::exchange(other.descriptor_, -1);
503 length_ = other.length_;
505 read_error_ = std::move(other.read_error_);
506 }
507 return *this;
508 }
509
511
512 [[nodiscard]] static std::optional<verified_body_file> open_and_verify(
513 const std::filesystem::path& root,
514 const body_artifact_reference& reference,
515 transport_status& failure_status, std::string& error
516 ) {
517 failure_status = transport_status::storage_error;
518 if (!crypto::is_safe_relative_artifact_ref(reference.storage_ref)) {
520 error = "body artifact reference is not a safe relative path";
521 return std::nullopt;
522 }
523 if (reference.byte_length > static_cast<std::uint64_t>(
524 std::numeric_limits<curl_off_t>::max()
525 )) {
526 failure_status = transport_status::invalid_request;
527 error = "body artifact is too large for this transport build";
528 return std::nullopt;
529 }
530 const std::filesystem::path path
531 = crypto::safe_artifact_path(root, reference.storage_ref);
532 bool unsafe = false;
533 if (!ensure_safe_existing_file(root, path, unsafe, error)) {
534 if (unsafe) {
536 }
537 return std::nullopt;
538 }
539
540 const int descriptor
541 = ::open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
542 if (descriptor < 0) {
543 error = std::string("cannot open body artifact: ")
544 + std::strerror(errno);
545 return std::nullopt;
546 }
547 verified_body_file file;
548 file.descriptor_ = descriptor;
549
550 struct stat state {};
551 if (::fstat(descriptor, &state) != 0 || !S_ISREG(state.st_mode)) {
552 error = "cannot stat body artifact as a regular file";
553 return std::nullopt;
554 }
555 if (state.st_size < 0
556 || static_cast<std::uint64_t>(state.st_size)
557 != reference.byte_length) {
558 failure_status = transport_status::invalid_request;
559 error = "body artifact byte_length does not match the file";
560 return std::nullopt;
561 }
562 file.length_ = reference.byte_length;
563
564 crypto::sha256_hasher hasher;
565 std::array<std::byte, 64U * 1024U> buffer {};
566 std::uint64_t bytes_read = 0;
567 for (;;) {
568 const ssize_t count
569 = ::read(descriptor, buffer.data(), buffer.size());
570 if (count < 0) {
571 if (errno == EINTR) {
572 continue;
573 }
574 error = std::string("cannot read body artifact: ")
575 + std::strerror(errno);
576 return std::nullopt;
577 }
578 if (count == 0) {
579 break;
580 }
581 const auto amount = static_cast<std::size_t>(count);
582 hasher.update(
583 std::span<const std::byte>(buffer.data(), amount)
584 );
585 bytes_read += static_cast<std::uint64_t>(amount);
586 if (bytes_read > reference.byte_length) {
587 error = "body artifact changed while it was verified";
588 return std::nullopt;
589 }
590 }
591 if (bytes_read != reference.byte_length
592 || hasher.finish_hex() != reference.sha256) {
593 failure_status = transport_status::invalid_request;
594 error = "body artifact SHA-256 does not match the contract";
595 return std::nullopt;
596 }
597 if (!file.rewind(error)) {
598 return std::nullopt;
599 }
600 return file;
601 }
602
603 [[nodiscard]] std::uint64_t length() const noexcept { return length_; }
604
605 [[nodiscard]] bool read_failed() const noexcept { return read_failed_; }
606
607 [[nodiscard]] const std::string& read_error() const noexcept {
608 return read_error_;
609 }
610
611 [[nodiscard]] bool rewind(std::string& error) noexcept {
612 if (::lseek(descriptor_, 0, SEEK_SET) < 0) {
613 error = std::string("cannot rewind body artifact: ")
614 + std::strerror(errno);
615 return false;
616 }
617 read_failed_ = false;
618 read_error_.clear();
619 return true;
620 }
621
622 std::size_t read(char* destination, const std::size_t size) noexcept {
623 for (;;) {
624 const ssize_t count = ::read(descriptor_, destination, size);
625 if (count >= 0) {
626 return static_cast<std::size_t>(count);
627 }
628 if (errno != EINTR) {
629 read_failed_ = true;
630 read_error_ = std::string("cannot stream body artifact: ")
631 + std::strerror(errno);
632 return CURL_READFUNC_ABORT;
633 }
634 }
635 }
636
637 int seek(const curl_off_t offset, const int origin) noexcept {
638 if (offset < std::numeric_limits<off_t>::min()
639 || offset > std::numeric_limits<off_t>::max()) {
640 return CURL_SEEKFUNC_FAIL;
641 }
642 return ::lseek(descriptor_, static_cast<off_t>(offset), origin) < 0
643 ? CURL_SEEKFUNC_FAIL
644 : CURL_SEEKFUNC_OK;
645 }
646
647 private:
648 void close() noexcept {
649 if (descriptor_ >= 0) {
650 ::close(descriptor_);
651 descriptor_ = -1;
652 }
653 }
654
655 int descriptor_ = -1;
657 bool read_failed_ = false;
658 std::string read_error_;
659 };
660
662 char* destination, const std::size_t size, const std::size_t count,
663 void* user_data
664 ) noexcept {
665 auto& body = *static_cast<verified_body_file*>(user_data);
666 if (size != 0
667 && count > std::numeric_limits<std::size_t>::max() / size) {
668 return CURL_READFUNC_ABORT;
669 }
670 return body.read(destination, size * count);
671 }
672
674 void* user_data, const curl_off_t offset, const int origin
675 ) noexcept {
676 return static_cast<verified_body_file*>(user_data)->seek(
677 offset, origin
678 );
679 }
680
681 class staging_file final {
682 public:
683 explicit staging_file(const std::filesystem::path& parent) {
684 for (unsigned attempt = 0; attempt < 128U; ++attempt) {
685 const std::uint64_t sequence
686 = stage_sequence.fetch_add(1, std::memory_order_relaxed);
687 path_ = parent
688 / (".arachne-stage-" + std::to_string(::getpid()) + "-"
689 + std::to_string(sequence));
690 descriptor_ = ::open(
691 path_.c_str(),
692 O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600
693 );
694 if (descriptor_ >= 0) {
695 return;
696 }
697 if (errno != EEXIST) {
698 throw std::system_error(
699 errno, std::generic_category(),
700 "cannot create artifact staging file"
701 );
702 }
703 }
704 throw std::runtime_error("cannot allocate unique staging filename");
705 }
706
707 staging_file(const staging_file&) = delete;
708 staging_file& operator=(const staging_file&) = delete;
709
711 close();
712 if (!path_.empty()) {
713 std::error_code ignored;
714 std::filesystem::remove(path_, ignored);
715 }
716 }
717
718 [[nodiscard]] int descriptor() const noexcept { return descriptor_; }
719
720 [[nodiscard]] const std::filesystem::path& path() const noexcept {
721 return path_;
722 }
723
724 [[nodiscard]] bool reset(std::string& error) noexcept {
725 if (::ftruncate(descriptor_, 0) != 0
726 || ::lseek(descriptor_, 0, SEEK_SET) < 0) {
727 error = std::string("cannot reset artifact staging file: ")
728 + std::strerror(errno);
729 return false;
730 }
731 return true;
732 }
733
734 [[nodiscard]] bool synchronize(std::string& error) noexcept {
735 if (::fsync(descriptor_) != 0) {
736 error
737 = std::string("cannot synchronize artifact staging file: ")
738 + std::strerror(errno);
739 return false;
740 }
741 return true;
742 }
743
744 void close() noexcept {
745 if (descriptor_ >= 0) {
746 ::close(descriptor_);
747 descriptor_ = -1;
748 }
749 }
750
751 void forget() noexcept { path_.clear(); }
752
753 private:
754 std::filesystem::path path_;
755 int descriptor_ = -1;
756 };
757
760 const int output_descriptor, const std::uint64_t limit
761 )
762 : descriptor(output_descriptor)
763 , max_bytes(limit) { }
764
768 std::size_t header_bytes = 0;
769 bool too_large = false;
770 bool headers_too_large = false;
771 bool write_failed = false;
772 std::string write_error;
773 std::string location;
775 crypto::sha256_hasher hasher;
776
777 void reset() {
778 byte_count = 0;
779 header_bytes = 0;
780 too_large = false;
781 headers_too_large = false;
782 write_failed = false;
783 write_error.clear();
784 location.clear();
785 headers.clear();
786 hasher.reset();
787 }
788 };
789
791 std::chrono::milliseconds read_timeout;
792 std::chrono::milliseconds write_timeout;
793 bool upload_expected = false;
794 bool read_timed_out = false;
795 bool write_timed_out = false;
796 curl_off_t downloaded = 0;
797 curl_off_t uploaded = 0;
798 std::chrono::steady_clock::time_point last_download_progress {};
799 std::chrono::steady_clock::time_point last_upload_progress {};
800
801 void reset() noexcept {
802 const auto now = std::chrono::steady_clock::now();
803 read_timed_out = false;
804 write_timed_out = false;
805 downloaded = 0;
806 uploaded = 0;
807 last_download_progress = now;
808 last_upload_progress = now;
809 }
810 };
811
813 void* user_data, const curl_off_t download_total,
814 const curl_off_t download_now, const curl_off_t upload_total,
815 const curl_off_t upload_now
816 ) noexcept {
817 auto& progress = *static_cast<progress_context*>(user_data);
818 const auto now = std::chrono::steady_clock::now();
819 if (download_now != progress.downloaded) {
820 progress.downloaded = download_now;
821 progress.last_download_progress = now;
822 }
823 if (upload_now != progress.uploaded) {
824 progress.uploaded = upload_now;
825 progress.last_upload_progress = now;
826 }
827 const bool download_complete
828 = download_total > 0 && download_now >= download_total;
829 const bool upload_complete = !progress.upload_expected
830 || (upload_total > 0 && upload_now >= upload_total);
831 if (upload_complete && !download_complete
832 && now - progress.last_download_progress > progress.read_timeout) {
833 progress.read_timed_out = true;
834 return 1;
835 }
836 if (!upload_complete
837 && now - progress.last_upload_progress > progress.write_timeout) {
838 progress.write_timed_out = true;
839 return 1;
840 }
841 return 0;
842 }
843
845 const int descriptor, const char* data, std::size_t size,
846 std::string& error
847 ) noexcept {
848 while (size != 0) {
849 const ssize_t written = ::write(descriptor, data, size);
850 if (written < 0) {
851 if (errno == EINTR) {
852 continue;
853 }
854 error = std::string("artifact write failed: ")
855 + std::strerror(errno);
856 return false;
857 }
858 if (written == 0) {
859 error = "artifact write made no progress";
860 return false;
861 }
862 const auto amount = static_cast<std::size_t>(written);
863 data += amount;
864 size -= amount;
865 }
866 return true;
867 }
868
869 std::size_t body_callback(
870 char* contents, const std::size_t size, const std::size_t count,
871 void* user_data
872 ) noexcept {
873 auto& context = *static_cast<receive_context*>(user_data);
874 if (size != 0
875 && count > std::numeric_limits<std::size_t>::max() / size) {
876 context.too_large = true;
877 return 0;
878 }
879 const std::size_t total = size * count;
880 if (total > context.max_bytes
881 || context.byte_count > context.max_bytes - total) {
882 context.too_large = true;
883 return 0;
884 }
885 if (!write_all(
886 context.descriptor, contents, total, context.write_error
887 )) {
888 context.write_failed = true;
889 return 0;
890 }
891 try {
892 context.hasher.update(std::as_bytes(std::span { contents, total }));
893 } catch (const std::exception& exception) {
894 context.write_failed = true;
895 context.write_error = exception.what();
896 return 0;
897 }
898 context.byte_count += total;
899 return total;
900 }
901
904 while (!value.empty()
905 && (value.back() == '\r' || value.back() == '\n')) {
907 }
908 while (!value.empty()
909 && (value.front() == ' ' || value.front() == '\t')) {
911 }
912 return value;
913 }
914
916 char* contents, const std::size_t size, const std::size_t count,
917 void* user_data
918 ) noexcept {
919 auto& context = *static_cast<receive_context*>(user_data);
920 if (size != 0
921 && count > std::numeric_limits<std::size_t>::max() / size) {
922 context.headers_too_large = true;
923 return 0;
924 }
925 const std::size_t total = size * count;
926 if (total > maximum_header_bytes
927 || context.header_bytes > maximum_header_bytes - total) {
928 context.headers_too_large = true;
929 return 0;
930 }
931 context.header_bytes += total;
932 const std::string_view line(contents, total);
933 if (line.starts_with("HTTP/")) {
934 context.headers.clear();
935 context.location.clear();
936 return total;
937 }
938 const std::size_t colon = line.find(':');
939 if (colon == std::string_view::npos) {
940 return total;
941 }
942 http_header header {
943 .name = std::string(line.substr(0, colon)),
944 .value = std::string(trim_header_value(line.substr(colon + 1U))),
945 };
946 if (!valid_header_name(header.name)) {
947 return total;
948 }
949 if (ascii_lower(header.name) == "location") {
950 context.location = header.value;
951 }
952 if (context.headers.size() < maximum_response_headers) {
953 context.headers.emplace_back(std::move(header));
954 }
955 return total;
956 }
957
958 [[nodiscard]] bool is_redirect_status(const long status) noexcept {
959 switch (status) {
960 case 300:
961 case 301:
962 case 302:
963 case 303:
964 case 305:
965 case 307:
966 case 308:
967 return true;
968 default:
969 return false;
970 }
971 }
972
973 [[nodiscard]] bool is_retryable_status(const long status) noexcept {
974 return status == 408 || status == 425 || status == 429 || status == 500
975 || status == 502 || status == 503 || status == 504;
976 }
977
978 [[nodiscard]] bool
979 is_retryable_curl_status(const CURLcode status) noexcept {
980 switch (status) {
981 case CURLE_COULDNT_RESOLVE_PROXY:
982 case CURLE_COULDNT_RESOLVE_HOST:
983 case CURLE_COULDNT_CONNECT:
984 case CURLE_PARTIAL_FILE:
985 case CURLE_HTTP2:
986 case CURLE_WRITE_ERROR:
987 case CURLE_UPLOAD_FAILED:
988 case CURLE_READ_ERROR:
989 case CURLE_OPERATION_TIMEDOUT:
990 case CURLE_ABORTED_BY_CALLBACK:
991 case CURLE_HTTP_POST_ERROR:
992 case CURLE_RECV_ERROR:
993 case CURLE_SEND_ERROR:
994 case CURLE_GOT_NOTHING:
995 case CURLE_AGAIN:
996#ifdef CURLE_HTTP3
997 case CURLE_HTTP3:
998#endif
999 return true;
1000 default:
1001 return false;
1002 }
1003 }
1004
1005 [[nodiscard]] std::optional<std::chrono::milliseconds>
1006 retry_after_delay(const std::vector<http_header>& headers) {
1007 for (const auto& header : headers) {
1008 if (ascii_lower(header.name) != "retry-after") {
1009 continue;
1010 }
1011 std::uint64_t seconds = 0;
1012 const char* begin = header.value.data();
1013 const char* end = begin + header.value.size();
1014 const auto parsed = std::from_chars(begin, end, seconds);
1015 if (parsed.ec == std::errc {} && parsed.ptr == end) {
1016 constexpr std::uint64_t maximum_seconds = 3'600U;
1017 return std::chrono::milliseconds(
1018 std::min(seconds, maximum_seconds) * 1'000U
1019 );
1020 }
1021 const std::time_t date
1022 = curl_getdate(header.value.c_str(), nullptr);
1023 if (date >= 0) {
1024 const std::time_t now = std::time(nullptr);
1025 const auto delay = std::max<std::time_t>(0, date - now);
1026 return std::chrono::milliseconds(
1027 std::min<std::time_t>(delay, 3'600) * 1'000
1028 );
1029 }
1030 return std::nullopt;
1031 }
1032 return std::nullopt;
1033 }
1034
1035 [[nodiscard]] std::chrono::milliseconds retry_delay(
1036 const fetch_request_v1& request, const std::size_t attempt,
1037 const std::vector<http_header>& headers
1038 ) {
1039 if (request.respect_retry_after) {
1040 if (const auto provided = retry_after_delay(headers)) {
1041 return std::min(*provided, request.maximum_retry_delay);
1042 }
1043 }
1044 std::int64_t delay = request.initial_retry_delay.count();
1045 for (std::size_t power = 1; power < attempt; ++power) {
1046 if (delay >= request.maximum_retry_delay.count() / 2) {
1047 delay = request.maximum_retry_delay.count();
1048 break;
1049 }
1050 delay *= 2;
1051 }
1052 delay = std::min(delay, request.maximum_retry_delay.count());
1053 const std::string jitter_seed
1054 = request.request_id + ":" + std::to_string(attempt);
1055 const std::string digest = crypto::sha256(jitter_seed);
1056 unsigned jitter_value = 0;
1057 static_cast<void>(
1058 std::from_chars(digest.data(), digest.data() + 4, jitter_value, 16)
1059 );
1060 const std::int64_t per_mille
1061 = 800 + static_cast<std::int64_t>(jitter_value % 401U);
1062 return std::chrono::milliseconds(delay * per_mille / 1'000);
1063 }
1064
1066 const std::chrono::steady_clock::time_point deadline
1067 ) noexcept {
1068 const auto remaining
1069 = std::chrono::duration_cast<std::chrono::milliseconds>(
1070 deadline - std::chrono::steady_clock::now()
1071 );
1072 if (remaining.count() <= 0) {
1073 return 0;
1074 }
1075 return static_cast<long>(std::min<std::int64_t>(
1076 remaining.count(), std::numeric_limits<long>::max()
1077 ));
1078 }
1079
1081 build_request_headers(const std::vector<http_header>& headers) {
1082 curl_slist* raw = nullptr;
1083 for (const auto& header : headers) {
1084 const std::string line = header.name + ":" + header.value;
1085 curl_slist* appended = curl_slist_append(raw, line.c_str());
1086 if (appended == nullptr) {
1087 curl_slist_free_all(raw);
1088 throw std::bad_alloc();
1089 }
1090 raw = appended;
1091 }
1092 return header_list(raw);
1093 }
1094
1096 acquired_artifact_v1 result, const transport_status status,
1097 std::string message
1098 ) {
1099 result.status = status;
1100 result.error_message = std::move(message);
1101 result.completed_at = std::chrono::system_clock::now();
1102 return result;
1103 }
1104
1106 const arachnespace::contracts::validation_result& validation
1107 ) {
1108 std::ostringstream message;
1109 message << "invalid fetch_request_v1 contract";
1110 for (const auto& diagnostic : validation.diagnostics) {
1111 message << "; "
1112 << (diagnostic.instance_path.empty()
1113 ? std::string_view { "/" }
1114 : std::string_view { diagnostic.instance_path })
1115 << " [" << diagnostic.code << "] " << diagnostic.message;
1116 }
1117 return message.str();
1118 }
1119
1120 [[nodiscard]] std::string
1121 rfc3339(const std::chrono::system_clock::time_point time) {
1122 const std::time_t converted
1123 = std::chrono::system_clock::to_time_t(time);
1124 std::tm utc {};
1125 if (::gmtime_r(&converted, &utc) == nullptr) {
1126 throw std::runtime_error("cannot format transport timestamp");
1127 }
1128 std::ostringstream output;
1129 output << std::put_time(&utc, "%Y-%m-%dT%H:%M:%SZ");
1130 return output.str();
1131 }
1132
1134 status_name(const transport_status status) noexcept {
1135 switch (status) {
1137 return "delivered";
1139 return "invalid_request";
1141 return "unsafe_artifact_ref";
1143 return "disallowed_host";
1145 return "redirect_rejected";
1147 return "redirect_limit_exceeded";
1149 return "timed_out";
1151 return "response_too_large";
1153 return "network_error";
1155 return "storage_error";
1157 return "artifact_exists";
1159 return "door_policy_rejected";
1161 return "cache_miss";
1163 return "checksum_mismatch";
1165 return "retry_budget_exhausted";
1167 return "admission_timeout";
1168 }
1169 return "unknown_transport_status";
1170 }
1171
1172 [[nodiscard]] std::optional<std::string>
1173 response_content_type(const std::vector<http_header>& headers) {
1174 for (const auto& header : headers) {
1175 if (ascii_lower(header.name) == "content-type"
1176 && !header.value.empty()) {
1177 return header.value;
1178 }
1179 }
1180 return std::nullopt;
1181 }
1182
1183 [[nodiscard]] bool sensitive_header_name(const std::string_view name) {
1184 const std::string lowered = ascii_lower(std::string(name));
1185 return lowered == "authorization" || lowered == "proxy-authorization"
1186 || lowered == "cookie" || lowered == "set-cookie"
1187 || lowered == "x-api-key" || lowered == "api-key"
1188 || lowered == "x-goog-api-key" || lowered == "x-auth-token"
1189 || lowered == "x-access-token" || lowered == "private-token";
1190 }
1191
1192 [[nodiscard]] int hexadecimal_value(const char character) noexcept {
1193 if (character >= '0' && character <= '9') {
1194 return character - '0';
1195 }
1196 if (character >= 'a' && character <= 'f') {
1197 return character - 'a' + 10;
1198 }
1199 if (character >= 'A' && character <= 'F') {
1200 return character - 'A' + 10;
1201 }
1202 return -1;
1203 }
1204
1205 [[nodiscard]] std::string decode_query_name(const std::string_view name) {
1206 std::string result;
1207 result.reserve(name.size());
1208 for (std::size_t index = 0; index < name.size(); ++index) {
1209 if (name[index] == '%' && index + 2U < name.size()) {
1210 const int high = hexadecimal_value(name[index + 1U]);
1211 const int low = hexadecimal_value(name[index + 2U]);
1212 if (high >= 0 && low >= 0) {
1213 result.push_back(static_cast<char>(high * 16 + low));
1214 index += 2U;
1215 continue;
1216 }
1217 }
1218 result.push_back(name[index] == '+' ? ' ' : name[index]);
1219 }
1220 return result;
1221 }
1222
1223 [[nodiscard]] bool sensitive_query_name(std::string name) {
1224 name = ascii_lower(decode_query_name(name));
1225 return name.contains("token") || name.contains("secret")
1226 || name.contains("password") || name.contains("signature")
1227 || name.contains("credential") || name.contains("api_key")
1228 || name.contains("apikey") || name.contains("access_key")
1229 || name == "key" || name == "auth" || name == "sig"
1230 || name.starts_with("x-amz-");
1231 }
1232
1233 [[nodiscard]] std::string redact_url_query(const std::string& url) {
1234 const std::size_t question = url.find('?');
1235 if (question == std::string::npos) {
1236 return url;
1237 }
1238 const std::size_t fragment = url.find('#', question + 1U);
1239 const std::size_t query_end
1240 = fragment == std::string::npos ? url.size() : fragment;
1241 std::string result = url.substr(0, question + 1U);
1242 std::string_view query(
1243 url.data() + question + 1U, query_end - question - 1U
1244 );
1245 bool first = true;
1246 while (!query.empty()) {
1247 const std::size_t ampersand = query.find('&');
1248 const std::string_view parameter = query.substr(0, ampersand);
1249 const std::size_t equals = parameter.find('=');
1250 const std::string name(parameter.substr(0, equals));
1251 if (!first) {
1252 result.push_back('&');
1253 }
1254 first = false;
1255 result += name;
1256 if (equals != std::string_view::npos) {
1257 result.push_back('=');
1258 result += sensitive_query_name(name)
1259 ? "%5BREDACTED%5D"
1260 : std::string(parameter.substr(equals + 1U));
1261 }
1262 if (ampersand == std::string_view::npos) {
1263 query = {};
1264 } else {
1265 query.remove_prefix(ampersand + 1U);
1266 }
1267 }
1268 if (fragment != std::string::npos) {
1269 result += url.substr(fragment);
1270 }
1271 return result;
1272 }
1273
1274 [[nodiscard]] std::string receipt_safe_text(const std::string_view value) {
1275 constexpr std::string_view hexadecimal = "0123456789ABCDEF";
1276 std::string result;
1277 result.reserve(value.size());
1278 for (const char raw_character : value) {
1279 const auto character = static_cast<unsigned char>(raw_character);
1280 if (character >= 0x20U && character <= 0x7eU) {
1281 result.push_back(static_cast<char>(character));
1282 } else {
1283 result.push_back('%');
1284 result.push_back(hexadecimal[character >> 4U]);
1285 result.push_back(hexadecimal[character & 0x0fU]);
1286 }
1287 }
1288 return result;
1289 }
1290
1291}
1292
1294 switch (value) {
1296 return "bulk_snapshot";
1298 return "incremental_harvest";
1300 return "point_lookup";
1302 return "resume_download";
1304 return "backend_read";
1306 return "external_write";
1307 }
1308 return "point_lookup";
1309}
1310
1312 switch (value) {
1314 return "fresh_required";
1316 return "cache_allowed";
1318 return "stale_allowed";
1320 return "offline_only";
1321 }
1322 return "fresh_required";
1323}
1324
1325std::string_view to_string(const delivery_mode value) noexcept {
1326 switch (value) {
1328 return "fetched";
1330 return "cache_validated";
1332 return "stale";
1334 return "resumed";
1336 return "offline";
1337 }
1338 return "fetched";
1339}
1340
1341fetch_request_v1 from_contract(const nlohmann::json& document) {
1342 using arachnespace::contracts::contract_name;
1343 const auto validation = arachnespace::contracts::validate(
1345 );
1346 if (!validation) {
1347 throw std::invalid_argument(validation_message(validation));
1348 }
1349
1350 try {
1351 fetch_request_v1 request;
1352 request.contract = document.at("contract").get<std::string>();
1353 request.format_version
1354 = document.at("format_version").get<std::uint32_t>();
1355 request.request_id = document.at("request_id").get<std::string>();
1356 request.door_id = document.value("door_id", std::string {});
1357 request.endpoint_id = document.value("endpoint_id", std::string {});
1358 const std::string operation
1359 = document.value("operation", std::string("point_lookup"));
1360 if (operation == "bulk_snapshot") {
1362 } else if (operation == "incremental_harvest") {
1364 } else if (operation == "resume_download") {
1366 } else if (operation == "backend_read") {
1368 } else if (operation == "external_write") {
1370 }
1371 const std::string freshness
1372 = document.value("freshness_policy", std::string("fresh_required"));
1373 if (freshness == "cache_allowed") {
1375 } else if (freshness == "stale_allowed") {
1377 } else if (freshness == "offline_only") {
1379 }
1380 request.idempotency_key
1381 = document.value("idempotency_key", std::string {});
1382 request.url = document.at("locator").get<std::string>();
1384 = document.at("output_ref").get<std::string>();
1385
1386 const std::string method = document.at("method").get<std::string>();
1387 request.method
1388 = method == "POST" ? http_method::post : http_method::get;
1389
1390 if (const auto headers = document.find("headers");
1391 headers != document.end()) {
1392 for (const auto& [name, value] : headers->items()) {
1393 request.headers.push_back({ name, value.get<std::string>() });
1394 }
1395 }
1396 if (const auto expected = document.find("expected");
1397 expected != document.end()) {
1398 if (const auto maximum_bytes = expected->find("maximum_bytes");
1399 maximum_bytes != expected->end()) {
1400 request.max_bytes = maximum_bytes->get<std::uint64_t>();
1401 }
1402 if (const auto timeout = expected->find("timeout_ms");
1403 timeout != expected->end()) {
1404 request.timeout
1405 = std::chrono::milliseconds(timeout->get<std::int64_t>());
1406 }
1407 if (const auto timeout = expected->find("connect_timeout_ms");
1408 timeout != expected->end()) {
1409 request.connect_timeout
1410 = std::chrono::milliseconds(timeout->get<std::int64_t>());
1411 }
1412 if (const auto timeout = expected->find("read_timeout_ms");
1413 timeout != expected->end()) {
1414 request.read_timeout
1415 = std::chrono::milliseconds(timeout->get<std::int64_t>());
1416 }
1417 if (const auto timeout = expected->find("write_timeout_ms");
1418 timeout != expected->end()) {
1419 request.write_timeout
1420 = std::chrono::milliseconds(timeout->get<std::int64_t>());
1421 }
1422 if (const auto checksum = expected->find("sha256");
1423 checksum != expected->end()) {
1424 request.expected_sha256 = checksum->get<std::string>();
1425 }
1426 }
1427 if (const auto retry = document.find("retry");
1428 retry != document.end()) {
1429 if (const auto attempts = retry->find("maximum_attempts");
1430 attempts != retry->end()) {
1431 request.maximum_attempts = attempts->get<std::size_t>();
1432 }
1433 if (const auto delay = retry->find("initial_delay_ms");
1434 delay != retry->end()) {
1435 request.initial_retry_delay
1436 = std::chrono::milliseconds(delay->get<std::int64_t>());
1437 }
1438 if (const auto delay = retry->find("maximum_delay_ms");
1439 delay != retry->end()) {
1440 request.maximum_retry_delay
1441 = std::chrono::milliseconds(delay->get<std::int64_t>());
1442 }
1443 if (const auto budget = retry->find("total_delay_budget_ms");
1444 budget != retry->end()) {
1445 request.total_retry_delay_budget
1446 = std::chrono::milliseconds(budget->get<std::int64_t>());
1447 }
1449 = retry->value("respect_retry_after", true);
1450 }
1451 if (const auto policy = document.find("redirect_policy");
1452 policy != document.end()) {
1453 request.redirects.follow = policy->at("follow").get<bool>();
1454 request.redirects.maximum_redirects
1455 = policy->at("maximum_redirects").get<std::size_t>();
1456 request.redirects.allow_https_to_http
1457 = policy->at("allow_https_to_http").get<bool>();
1458 request.redirects.allowed_hosts
1459 = policy->at("allowed_hosts").get<std::vector<std::string>>();
1460 }
1461 if (const auto artifact = document.find("body_artifact");
1462 artifact != document.end()) {
1463 request.body_artifact = body_artifact_reference {
1464 .storage_ref = artifact->at("storage_ref").get<std::string>(),
1465 .sha256 = artifact->at("sha256").get<std::string>(),
1466 .byte_length = artifact->at("byte_length").get<std::uint64_t>(),
1467 };
1468 }
1469 if (const auto artifact = document.find("resume_artifact");
1470 artifact != document.end()) {
1471 request.resume_artifact = body_artifact_reference {
1472 .storage_ref = artifact->at("storage_ref").get<std::string>(),
1473 .sha256 = artifact->at("sha256").get<std::string>(),
1474 .byte_length = artifact->at("byte_length").get<std::uint64_t>(),
1475 };
1476 }
1477 return request;
1478 } catch (const nlohmann::json::exception& exception) {
1479 throw std::invalid_argument(
1480 std::string("cannot decode fetch_request_v1: ") + exception.what()
1481 );
1482 }
1483}
1484
1486 using ordered_json = nlohmann::ordered_json;
1487 ordered_json document = ordered_json::object();
1488 document["contract"] = "acquired_artifact_v1";
1489 document["format_version"] = 1;
1490 document["artifact_id"] = artifact.artifact_id.empty()
1491 ? artifact_id_for(artifact.request_id)
1492 : artifact.artifact_id;
1493 document["request_id"] = artifact.request_id;
1494 if (!artifact.door_id.empty()) {
1495 document["door_id"] = artifact.door_id;
1496 }
1497 document["operation"] = std::string(to_string(artifact.operation));
1498 document["source_locator"]
1499 = receipt_safe_text(redact_url_query(artifact.source_url));
1500
1501 ordered_json transport_metadata = ordered_json::object();
1502 transport_metadata["status"]
1503 = artifact.delivered() ? "delivered" : "failed";
1504 transport_metadata["attempts"] = artifact.attempts;
1505 transport_metadata["delivery_mode"]
1506 = std::string(to_string(artifact.delivered_via));
1507 if (artifact.retry_after) {
1508 transport_metadata["retry_after_ms"] = artifact.retry_after->count();
1509 }
1510 if (!artifact.delivered()) {
1511 transport_metadata["error_code"]
1512 = std::string(status_name(artifact.status));
1513 transport_metadata["error_message"] = artifact.error_message.empty()
1514 ? "transport failed without additional detail"
1515 : receipt_safe_text(artifact.error_message);
1516 }
1517 document["transport"] = std::move(transport_metadata);
1518
1519 ordered_json response_metadata = ordered_json::object();
1520 response_metadata["status_code"] = artifact.http_status;
1521 if (!artifact.effective_url.empty()) {
1522 response_metadata["effective_url"]
1523 = receipt_safe_text(redact_url_query(artifact.effective_url));
1524 }
1525 response_metadata["headers"] = ordered_json::array();
1526 for (const auto& header : artifact.response_headers) {
1527 response_metadata["headers"].push_back(
1528 { { "name", receipt_safe_text(header.name) },
1529 { "value",
1530 sensitive_header_name(header.name)
1531 ? "[REDACTED]"
1532 : receipt_safe_text(header.value) } }
1533 );
1534 }
1535 response_metadata["redirect_chain"] = ordered_json::array();
1536 for (const auto& redirect : artifact.redirect_chain) {
1537 response_metadata["redirect_chain"].push_back(
1538 receipt_safe_text(redact_url_query(redirect))
1539 );
1540 }
1541 response_metadata["started_at"] = rfc3339(artifact.started_at);
1542 response_metadata["completed_at"] = rfc3339(artifact.completed_at);
1543 document["response_metadata"] = std::move(response_metadata);
1544
1545 if (artifact.delivered()) {
1546 ordered_json artifact_reference = ordered_json::object();
1547 artifact_reference["storage_ref"] = artifact.artifact_ref;
1548 artifact_reference["sha256"] = artifact.sha256;
1549 artifact_reference["byte_length"] = artifact.byte_count;
1550 if (const auto content_type
1551 = response_content_type(artifact.response_headers)) {
1552 artifact_reference["media_type"] = receipt_safe_text(*content_type);
1553 }
1554 document["artifact"] = std::move(artifact_reference);
1555 }
1556 document["acquired_at"] = rfc3339(artifact.completed_at);
1557
1558 const nlohmann::json validation_document = document;
1559 const auto validation = arachnespace::contracts::validate(
1561 validation_document
1562 );
1563 if (!validation) {
1564 throw std::invalid_argument(validation_message(validation));
1565 }
1566 return document;
1567}
1568
1569transport::transport(std::filesystem::path artifact_root) {
1571 if (artifact_root.empty()) {
1572 throw std::invalid_argument("artifact root must not be empty");
1573 }
1574 std::error_code ec;
1575 std::filesystem::create_directories(artifact_root, ec);
1576 if (ec) {
1577 throw std::system_error(ec, "cannot create artifact root");
1578 }
1579 artifact_root_ = std::filesystem::canonical(artifact_root, ec);
1580 if (ec || !std::filesystem::is_directory(artifact_root_)) {
1581 throw std::system_error(
1582 ec ? ec : std::make_error_code(std::errc::not_a_directory),
1583 "artifact root is not a directory"
1584 );
1585 }
1586}
1587
1588acquired_artifact_v1 transport::execute(const fetch_request_v1& request) const {
1589 acquired_artifact_v1 result;
1590 result.artifact_id = "artifact-invalid";
1591 result.request_id = request.request_id;
1592 result.door_id = request.door_id;
1593 result.operation = request.operation;
1594 result.delivered_via
1598 result.source_url = request.url;
1599 result.started_at = std::chrono::system_clock::now();
1600
1601 if (request.contract != "fetch_request_v1"
1602 || request.format_version != 1U) {
1603 return fail_result(
1604 std::move(result), transport_status::invalid_request,
1605 "unsupported fetch request format version"
1606 );
1607 }
1608 if (!valid_request_id(request.request_id)) {
1609 return fail_result(
1610 std::move(result), transport_status::invalid_request,
1611 "request_id is not a supported stable identifier"
1612 );
1613 }
1614 result.artifact_id = artifact_id_for(request.request_id);
1615 if (request.timeout.count() <= 0 || request.connect_timeout.count() <= 0
1616 || request.read_timeout.count() <= 0
1617 || request.write_timeout.count() <= 0 || request.max_bytes == 0U) {
1618 return fail_result(
1619 std::move(result), transport_status::invalid_request,
1620 "timeouts and max_bytes must be positive"
1621 );
1622 }
1623 if (request.initial_retry_delay.count() < 0
1624 || request.maximum_retry_delay.count() < 0
1625 || request.total_retry_delay_budget.count() < 0
1626 || request.initial_retry_delay > request.maximum_retry_delay) {
1627 return fail_result(
1628 std::move(result), transport_status::invalid_request,
1629 "retry delays are negative or internally inconsistent"
1630 );
1631 }
1632 if (request.maximum_attempts == 0U
1634 return fail_result(
1635 std::move(result), transport_status::invalid_request,
1636 "maximum_attempts must be between one and twenty"
1637 );
1638 }
1639 if (request.redirects.maximum_redirects > maximum_redirects
1640 || (!request.redirects.follow
1641 && request.redirects.maximum_redirects != 0U)) {
1642 return fail_result(
1643 std::move(result), transport_status::invalid_request,
1644 "redirect limit is inconsistent or exceeds the safety maximum"
1645 );
1646 }
1647 if (request.method == http_method::get
1648 && (!request.body.empty() || request.body_artifact)) {
1649 return fail_result(
1650 std::move(result), transport_status::invalid_request,
1651 "GET requests cannot contain a body"
1652 );
1653 }
1654 if (!request.body.empty() && request.body_artifact) {
1655 return fail_result(
1656 std::move(result), transport_status::invalid_request,
1657 "request cannot contain both inline and artifact body bytes"
1658 );
1659 }
1661 != request.resume_artifact.has_value()) {
1662 return fail_result(
1663 std::move(result), transport_status::invalid_request,
1664 "resume_download requires exactly one resume_artifact"
1665 );
1666 }
1667 if (request.resume_artifact
1668 && (request.method != http_method::get
1669 || request.resume_artifact->byte_length == 0U
1670 || request.resume_artifact->byte_length >= request.max_bytes
1671 || request.resume_artifact->storage_ref
1672 == request.target_artifact_ref)) {
1673 return fail_result(
1674 std::move(result), transport_status::invalid_request,
1675 "resume artifact, method, target, or size is inconsistent"
1676 );
1677 }
1678 if (request.body.size()
1679 > static_cast<std::size_t>(std::numeric_limits<curl_off_t>::max())) {
1680 return fail_result(
1681 std::move(result), transport_status::invalid_request,
1682 "inline request body is too large for this transport build"
1683 );
1684 }
1685 if (!std::ranges::all_of(request.headers, valid_header)) {
1686 return fail_result(
1687 std::move(result), transport_status::invalid_request,
1688 "request contains an invalid HTTP header"
1689 );
1690 }
1691 if (!crypto::is_safe_relative_artifact_ref(request.target_artifact_ref)) {
1692 return fail_result(
1693 std::move(result), transport_status::unsafe_artifact_ref,
1694 "target artifact reference is not a safe relative path"
1695 );
1696 }
1697
1698 std::string validation_error;
1699 auto current = parse_url(request.url, validation_error);
1700 if (!current) {
1701 return fail_result(
1702 std::move(result), transport_status::invalid_request,
1703 std::move(validation_error)
1704 );
1705 }
1706 if (!host_allowed(
1707 *current, request.redirects.allowed_hosts, validation_error
1708 )) {
1709 return fail_result(
1710 std::move(result), transport_status::disallowed_host,
1711 std::move(validation_error)
1712 );
1713 }
1714
1715 std::optional<verified_body_file> body_file;
1716 if (request.body_artifact) {
1718 body_file = verified_body_file::open_and_verify(
1719 artifact_root_, *request.body_artifact, body_status,
1720 validation_error
1721 );
1722 if (!body_file) {
1723 return fail_result(
1724 std::move(result), body_status, std::move(validation_error)
1725 );
1726 }
1727 }
1728 std::optional<verified_body_file> resume_file;
1729 if (request.resume_artifact) {
1731 resume_file = verified_body_file::open_and_verify(
1732 artifact_root_, *request.resume_artifact, resume_status,
1733 validation_error
1734 );
1735 if (!resume_file) {
1736 return fail_result(
1737 std::move(result), resume_status, std::move(validation_error)
1738 );
1739 }
1740 }
1741
1742 const std::filesystem::path target = crypto::safe_artifact_path(
1743 artifact_root_, request.target_artifact_ref
1744 );
1745 if (!ensure_safe_parent(artifact_root_, target, validation_error)) {
1746 return fail_result(
1747 std::move(result), transport_status::storage_error,
1748 std::move(validation_error)
1749 );
1750 }
1751 std::error_code ec;
1752 const auto target_state = std::filesystem::symlink_status(target, ec);
1753 if ((!ec && std::filesystem::exists(target_state))
1754 || ec == std::errc::too_many_symbolic_link_levels) {
1755 return fail_result(
1756 std::move(result), transport_status::artifact_exists,
1757 "target artifact already exists"
1758 );
1759 }
1760 if (ec && ec != std::errc::no_such_file_or_directory) {
1761 return fail_result(
1762 std::move(result), transport_status::storage_error,
1763 "cannot inspect target artifact: " + ec.message()
1764 );
1765 }
1766
1767 std::optional<staging_file> staging;
1768 try {
1769 staging.emplace(target.parent_path());
1770 } catch (const std::exception& exception) {
1771 return fail_result(
1772 std::move(result), transport_status::storage_error, exception.what()
1773 );
1774 }
1775
1776 easy_handle handle(curl_easy_init());
1777 if (!handle) {
1778 return fail_result(
1779 std::move(result), transport_status::network_error,
1780 "curl_easy_init failed"
1781 );
1782 }
1783
1784 header_list request_headers;
1785 try {
1786 request_headers = build_request_headers(request.headers);
1787 } catch (const std::exception& exception) {
1788 return fail_result(
1789 std::move(result), transport_status::invalid_request,
1790 exception.what()
1791 );
1792 }
1793
1794 receive_context received(staging->descriptor(), request.max_bytes);
1795 progress_context progress {
1796 .read_timeout = request.read_timeout,
1797 .write_timeout = request.write_timeout,
1798 .upload_expected = request.method == http_method::post
1799 && (!request.body.empty()
1800 || (body_file && body_file->length() != 0U)),
1801 };
1802 progress.reset();
1803 std::array<char, CURL_ERROR_SIZE> curl_error {};
1804 curl_easy_setopt(handle.get(), CURLOPT_ERRORBUFFER, curl_error.data());
1805 curl_easy_setopt(handle.get(), CURLOPT_NOSIGNAL, 1L);
1806 curl_easy_setopt(handle.get(), CURLOPT_SHARE, curl_connection_share);
1807 curl_easy_setopt(handle.get(), CURLOPT_MAXCONNECTS, 64L);
1808 curl_easy_setopt(handle.get(), CURLOPT_FOLLOWLOCATION, 0L);
1809 curl_easy_setopt(handle.get(), CURLOPT_NOPROGRESS, 0L);
1810 curl_easy_setopt(
1811 handle.get(), CURLOPT_XFERINFOFUNCTION, transfer_progress_callback
1812 );
1813 curl_easy_setopt(handle.get(), CURLOPT_XFERINFODATA, &progress);
1814 curl_easy_setopt(handle.get(), CURLOPT_PROTOCOLS_STR, "http,https");
1815 curl_easy_setopt(handle.get(), CURLOPT_REDIR_PROTOCOLS_STR, "http,https");
1816 curl_easy_setopt(handle.get(), CURLOPT_HTTP_CONTENT_DECODING, 0L);
1817 curl_easy_setopt(handle.get(), CURLOPT_FAILONERROR, 0L);
1818 curl_easy_setopt(
1819 handle.get(), CURLOPT_CONNECTTIMEOUT_MS,
1820 static_cast<long>(std::min<std::int64_t>(
1821 request.connect_timeout.count(), std::numeric_limits<long>::max()
1822 ))
1823 );
1824 curl_easy_setopt(handle.get(), CURLOPT_WRITEFUNCTION, body_callback);
1825 curl_easy_setopt(handle.get(), CURLOPT_WRITEDATA, &received);
1826 curl_easy_setopt(
1827 handle.get(), CURLOPT_HEADERFUNCTION, response_header_callback
1828 );
1829 curl_easy_setopt(handle.get(), CURLOPT_HEADERDATA, &received);
1830 curl_easy_setopt(
1831 handle.get(), CURLOPT_MAXFILESIZE_LARGE,
1832 static_cast<curl_off_t>(std::min<std::uint64_t>(
1833 request.max_bytes,
1834 static_cast<std::uint64_t>(std::numeric_limits<curl_off_t>::max())
1835 ))
1836 );
1837 if (request_headers) {
1838 curl_easy_setopt(
1839 handle.get(), CURLOPT_HTTPHEADER, request_headers.get()
1840 );
1841 }
1842 if (request.method == http_method::post) {
1843 curl_easy_setopt(handle.get(), CURLOPT_POST, 1L);
1844 if (body_file) {
1845 curl_easy_setopt(
1846 handle.get(), CURLOPT_READFUNCTION, body_read_callback
1847 );
1848 curl_easy_setopt(handle.get(), CURLOPT_READDATA, &*body_file);
1849 curl_easy_setopt(
1850 handle.get(), CURLOPT_SEEKFUNCTION, body_seek_callback
1851 );
1852 curl_easy_setopt(handle.get(), CURLOPT_SEEKDATA, &*body_file);
1853 curl_easy_setopt(
1854 handle.get(), CURLOPT_POSTFIELDSIZE_LARGE,
1855 static_cast<curl_off_t>(body_file->length())
1856 );
1857 } else {
1858 curl_easy_setopt(
1859 handle.get(), CURLOPT_POSTFIELDS, request.body.data()
1860 );
1861 curl_easy_setopt(
1862 handle.get(), CURLOPT_POSTFIELDSIZE_LARGE,
1863 static_cast<curl_off_t>(request.body.size())
1864 );
1865 }
1866 } else {
1867 curl_easy_setopt(handle.get(), CURLOPT_HTTPGET, 1L);
1868 }
1869 if (resume_file) {
1870 curl_easy_setopt(
1871 handle.get(), CURLOPT_RESUME_FROM_LARGE,
1872 static_cast<curl_off_t>(resume_file->length())
1873 );
1874 }
1875
1876 const auto deadline = std::chrono::steady_clock::now() + request.timeout;
1877 const parsed_url initial_url = *current;
1878 bool transfer_completed = false;
1879 std::chrono::milliseconds retry_delay_spent { 0 };
1880 auto prepare_staging = [&]() -> bool {
1881 if (!staging->reset(validation_error)) {
1882 return false;
1883 }
1884 received.reset();
1885 if (!resume_file) {
1886 return true;
1887 }
1888 if (!resume_file->rewind(validation_error)) {
1889 return false;
1890 }
1891 std::array<char, 64U * 1024U> buffer {};
1892 for (;;) {
1893 const std::size_t count
1894 = resume_file->read(buffer.data(), buffer.size());
1895 if (resume_file->read_failed()) {
1896 validation_error = resume_file->read_error();
1897 return false;
1898 }
1899 if (count == 0U) {
1900 break;
1901 }
1902 if (count > received.max_bytes
1903 || received.byte_count > received.max_bytes - count
1904 || !write_all(
1905 staging->descriptor(), buffer.data(), count,
1906 validation_error
1907 )) {
1908 if (validation_error.empty()) {
1909 validation_error = "resume artifact exceeds max_bytes";
1910 }
1911 return false;
1912 }
1913 received.hasher.update(
1914 std::as_bytes(std::span(buffer.data(), count))
1915 );
1916 received.byte_count += static_cast<std::uint64_t>(count);
1917 }
1918 return true;
1919 };
1920 for (std::size_t attempt = 1;
1921 attempt <= request.maximum_attempts && !transfer_completed;
1922 ++attempt) {
1923 result.attempts = attempt;
1924 current = initial_url;
1925 result.redirect_chain.clear();
1926 std::size_t redirect_count = 0;
1927 if (!prepare_staging()) {
1928 return fail_result(
1929 std::move(result), transport_status::storage_error,
1930 std::move(validation_error)
1931 );
1932 }
1933
1934 for (;;) {
1935 const long remaining = timeout_milliseconds(deadline);
1936 if (remaining <= 0) {
1937 return fail_result(
1938 std::move(result), transport_status::timed_out,
1939 "transport deadline expired"
1940 );
1941 }
1942 curl_easy_setopt(handle.get(), CURLOPT_TIMEOUT_MS, remaining);
1943 if (body_file) {
1944 std::string rewind_error;
1945 if (!body_file->rewind(rewind_error)) {
1946 return fail_result(
1947 std::move(result), transport_status::storage_error,
1948 std::move(rewind_error)
1949 );
1950 }
1951 }
1952 curl_easy_setopt(
1953 handle.get(), CURLOPT_URL, current->normalized_url.c_str()
1954 );
1955 progress.reset();
1956 curl_error.fill('\0');
1957 const CURLcode curl_status = curl_easy_perform(handle.get());
1958
1959 char* effective_raw = nullptr;
1960 curl_easy_getinfo(
1961 handle.get(), CURLINFO_EFFECTIVE_URL, &effective_raw
1962 );
1963 result.effective_url = effective_raw == nullptr
1964 ? current->normalized_url
1965 : std::string(effective_raw);
1966 curl_easy_getinfo(
1967 handle.get(), CURLINFO_RESPONSE_CODE, &result.http_status
1968 );
1969 result.response_headers = received.headers;
1970 result.byte_count = received.byte_count;
1971
1972 if (received.too_large || curl_status == CURLE_FILESIZE_EXCEEDED) {
1973 return fail_result(
1974 std::move(result), transport_status::response_too_large,
1975 "response exceeded max_bytes"
1976 );
1977 }
1978 if (received.headers_too_large) {
1979 return fail_result(
1980 std::move(result), transport_status::response_too_large,
1981 "response headers exceeded the safety limit"
1982 );
1983 }
1984 if (received.write_failed) {
1985 return fail_result(
1986 std::move(result), transport_status::storage_error,
1987 std::move(received.write_error)
1988 );
1989 }
1990 if (body_file && body_file->read_failed()) {
1991 return fail_result(
1992 std::move(result), transport_status::storage_error,
1993 body_file->read_error()
1994 );
1995 }
1996 if (curl_status != CURLE_OK) {
1997 const transport_status status
1998 = curl_status == CURLE_OPERATION_TIMEDOUT
1999 || progress.read_timed_out || progress.write_timed_out
2002 const std::string message = progress.read_timed_out
2003 ? "transport read-progress timeout expired"
2004 : progress.write_timed_out
2005 ? "transport write-progress timeout expired"
2006 : curl_error.front() == '\0'
2007 ? curl_easy_strerror(curl_status)
2008 : curl_error.data();
2009 const bool may_retry = is_retryable_curl_status(curl_status)
2010 && attempt < request.maximum_attempts
2011 && timeout_milliseconds(deadline) > 0;
2012 if (may_retry) {
2013 const auto delay = retry_delay(request, attempt, {});
2014 if (retry_delay_spent + delay
2015 > request.total_retry_delay_budget) {
2016 return fail_result(
2017 std::move(result),
2019 "network retry delay budget was exhausted"
2020 );
2021 }
2022 if (delay.count() >= timeout_milliseconds(deadline)) {
2023 return fail_result(
2024 std::move(result), transport_status::timed_out,
2025 "transport deadline would expire during retry delay"
2026 );
2027 }
2028 retry_delay_spent += delay;
2029 std::this_thread::sleep_for(delay);
2030 break;
2031 }
2032 return fail_result(std::move(result), status, message);
2033 }
2034
2036 && !received.location.empty()) {
2037 if (!request.redirects.follow) {
2038 return fail_result(
2039 std::move(result), transport_status::redirect_rejected,
2040 "response attempted a redirect while redirects are "
2041 "disabled"
2042 );
2043 }
2044 if (redirect_count >= request.redirects.maximum_redirects) {
2045 return fail_result(
2046 std::move(result),
2048 "response exceeded the configured redirect limit"
2049 );
2050 }
2051
2052 std::string redirect_error;
2053 auto redirected_url = resolve_redirect(
2054 result.effective_url, received.location, redirect_error
2055 );
2056 auto redirected = redirected_url
2057 ? parse_url(*redirected_url, redirect_error)
2058 : std::nullopt;
2059 if (!redirected) {
2060 return fail_result(
2061 std::move(result), transport_status::redirect_rejected,
2062 std::move(redirect_error)
2063 );
2064 }
2065 if (current->scheme == "https" && redirected->scheme == "http"
2066 && !request.redirects.allow_https_to_http) {
2067 return fail_result(
2068 std::move(result), transport_status::redirect_rejected,
2069 "HTTPS-to-HTTP redirect is prohibited"
2070 );
2071 }
2072 if (!host_allowed(
2073 *redirected, request.redirects.allowed_hosts,
2074 redirect_error
2075 )) {
2076 return fail_result(
2077 std::move(result), transport_status::disallowed_host,
2078 std::move(redirect_error)
2079 );
2080 }
2081 result.redirect_chain.emplace_back(redirected->normalized_url);
2082 current = std::move(redirected);
2083 ++redirect_count;
2084 if (!prepare_staging()) {
2085 return fail_result(
2086 std::move(result), transport_status::storage_error,
2087 std::move(validation_error)
2088 );
2089 }
2090 continue;
2091 }
2092 if (resume_file && result.http_status != 206) {
2093 return fail_result(
2094 std::move(result), transport_status::network_error,
2095 "provider did not honor the resumable byte-range request"
2096 );
2097 }
2098 result.retry_after = retry_after_delay(received.headers);
2100 && attempt < request.maximum_attempts) {
2101 const auto delay
2102 = retry_delay(request, attempt, received.headers);
2103 if (retry_delay_spent + delay
2104 > request.total_retry_delay_budget) {
2105 return fail_result(
2106 std::move(result),
2108 "HTTP retry delay budget was exhausted"
2109 );
2110 }
2111 if (delay.count() >= timeout_milliseconds(deadline)) {
2112 return fail_result(
2113 std::move(result), transport_status::timed_out,
2114 "transport deadline would expire during retry delay"
2115 );
2116 }
2117 retry_delay_spent += delay;
2118 std::this_thread::sleep_for(delay);
2119 break;
2120 }
2121 transfer_completed = true;
2122 break;
2123 }
2124 }
2125
2126 std::string synchronize_error;
2127 if (!staging->synchronize(synchronize_error)) {
2128 return fail_result(
2129 std::move(result), transport_status::storage_error,
2130 std::move(synchronize_error)
2131 );
2132 }
2133 const std::string digest = received.hasher.finish_hex();
2134 if (request.expected_sha256 && digest != *request.expected_sha256) {
2135 return fail_result(
2136 std::move(result), transport_status::checksum_mismatch,
2137 "delivered bytes do not match expected SHA-256"
2138 );
2139 }
2140 staging->close();
2141
2142 ec.clear();
2143 std::filesystem::create_hard_link(staging->path(), target, ec);
2144 if (ec) {
2145 const transport_status status = ec == std::errc::file_exists
2148 return fail_result(
2149 std::move(result), status,
2150 "cannot atomically publish artifact: " + ec.message()
2151 );
2152 }
2153 ec.clear();
2154 std::filesystem::remove(staging->path(), ec);
2155 if (!ec) {
2156 staging->forget();
2157 }
2158
2160 result.artifact_ref = request.target_artifact_ref;
2161 result.sha256 = digest;
2162 result.byte_count = received.byte_count;
2163 result.error_message.clear();
2164 result.completed_at = std::chrono::system_clock::now();
2165 return result;
2166}
2167
2169transport::execute(const nlohmann::json& request_contract) const {
2170 return execute(from_contract(request_contract));
2171}
2172
2173}
verified_body_file & operator=(verified_body_file &&other) noexcept
std::size_t read(char *destination, const std::size_t size) noexcept
static std::optional< verified_body_file > open_and_verify(const std::filesystem::path &root, const body_artifact_reference &reference, transport_status &failure_status, std::string &error)
verified_body_file & operator=(const verified_body_file &)=delete
int seek(const curl_off_t offset, const int origin) noexcept
transport(std::filesystem::path artifact_root)
acquired_artifact_v1 execute(const nlohmann::json &request_contract) const
acquired_artifact_v1 execute(const fetch_request_v1 &request) const
std::string sha256(std::span< const std::byte > bytes)
Definition crypto.cpp:209
std::string receipt_safe_text(const std::string_view value)
acquired_artifact_v1 fail_result(acquired_artifact_v1 result, const transport_status status, std::string message)
std::string redact_url_query(const std::string &url)
header_list build_request_headers(const std::vector< http_header > &headers)
std::string_view status_name(const transport_status status) noexcept
int body_seek_callback(void *user_data, const curl_off_t offset, const int origin) noexcept
std::string artifact_id_for(const std::string_view request_id)
bool valid_request_id(const std::string_view request_id)
std::size_t body_callback(char *contents, const std::size_t size, const std::size_t count, void *user_data) noexcept
std::unique_ptr< curl_slist, curl_slist_deleter > header_list
Definition transport.cpp:79
bool is_retryable_curl_status(const CURLcode status) noexcept
bool write_all(const int descriptor, const char *data, std::size_t size, std::string &error) noexcept
std::optional< std::chrono::milliseconds > retry_after_delay(const std::vector< http_header > &headers)
bool ensure_safe_existing_file(const std::filesystem::path &root, const std::filesystem::path &candidate, bool &unsafe, std::string &error)
bool valid_header_name(const std::string_view name)
std::optional< std::string > normalize_host(const std::string_view input)
std::optional< std::string > resolve_redirect(const std::string &base, const std::string &location, std::string &error)
void curl_share_lock(CURL *, const curl_lock_data, const curl_lock_access, void *) noexcept
Definition transport.cpp:49
std::string decode_query_name(const std::string_view name)
std::chrono::milliseconds retry_delay(const fetch_request_v1 &request, const std::size_t attempt, const std::vector< http_header > &headers)
int hexadecimal_value(const char character) noexcept
bool ensure_safe_parent(const std::filesystem::path &root, const std::filesystem::path &target, std::string &error)
std::size_t body_read_callback(char *destination, const std::size_t size, const std::size_t count, void *user_data) noexcept
std::unique_ptr< CURLU, curl_url_deleter > url_handle
Definition transport.cpp:78
std::unique_ptr< CURL, curl_easy_deleter > easy_handle
Definition transport.cpp:77
std::optional< parsed_url > parse_url(const std::string &value, std::string &error)
std::string rfc3339(const std::chrono::system_clock::time_point time)
void curl_share_unlock(CURL *, const curl_lock_data, void *) noexcept
Definition transport.cpp:55
std::optional< allowed_authority > parse_allowed_authority(const std::string_view input)
std::size_t response_header_callback(char *contents, const std::size_t size, const std::size_t count, void *user_data) noexcept
std::optional< std::string > response_content_type(const std::vector< http_header > &headers)
std::string_view trim_header_value(std::string_view value) noexcept
bool sensitive_header_name(const std::string_view name)
long timeout_milliseconds(const std::chrono::steady_clock::time_point deadline) noexcept
int transfer_progress_callback(void *user_data, const curl_off_t download_total, const curl_off_t download_now, const curl_off_t upload_total, const curl_off_t upload_now) noexcept
bool host_allowed(const parsed_url &url, const std::vector< std::string > &allowed_hosts, std::string &error)
bool path_begins_with(const std::filesystem::path &path, const std::filesystem::path &root)
std::string validation_message(const arachnespace::contracts::validation_result &validation)
std::optional< unsigned > parse_port(const std::string_view text) noexcept
fetch_request_v1 from_contract(const nlohmann::json &document)
nlohmann::ordered_json to_contract(const acquired_artifact_v1 &artifact)
std::string_view to_string(freshness_policy value) noexcept
std::string_view to_string(delivery_mode value) noexcept
std::string_view to_string(transport_operation value) noexcept
receive_context(const int output_descriptor, const std::uint64_t limit)