1343 const auto validation = arachnespace::contracts::validate(
1347 throw std::invalid_argument(validation_message(validation));
1352 request.contract = document.at(
"contract").get<std::string>();
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") {
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") {
1381 = document.value(
"idempotency_key", std::string {});
1382 request
.url = document.at(
"locator").get<std::string>();
1384 = document.at(
"output_ref").get<std::string>();
1386 const std::string method = document.at(
"method").get<std::string>();
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>() });
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>();
1402 if (
const auto timeout = expected->find(
"timeout_ms");
1403 timeout != expected->end()) {
1405 = std::chrono::milliseconds(timeout->get<std::int64_t>());
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>());
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>());
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>());
1422 if (
const auto checksum = expected->find(
"sha256");
1423 checksum != expected->end()) {
1424 request.expected_sha256 = checksum->get<std::string>();
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()) {
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>());
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>());
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>());
1449 = retry->value(
"respect_retry_after",
true);
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>>();
1461 if (
const auto artifact = document.find(
"body_artifact");
1462 artifact != document.end()) {
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>(),
1469 if (
const auto artifact = document.find(
"resume_artifact");
1470 artifact != document.end()) {
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>(),
1478 }
catch (
const nlohmann::json::exception& exception) {
1479 throw std::invalid_argument(
1480 std::string(
"cannot decode fetch_request_v1: ") + exception.what()
1599 result.started_at = std::chrono::system_clock::now();
1601 if (request.contract !=
"fetch_request_v1"
1605 "unsupported fetch request format version"
1608 if (!valid_request_id(request.request_id)) {
1611 "request_id is not a supported stable identifier"
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) {
1620 "timeouts and max_bytes must be positive"
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) {
1629 "retry delays are negative or internally inconsistent"
1636 "maximum_attempts must be between one and twenty"
1640 || (!request.redirects.follow
1641 && request.redirects.maximum_redirects != 0U)) {
1644 "redirect limit is inconsistent or exceeds the safety maximum"
1648 && (!request
.body.empty() || request.body_artifact)) {
1651 "GET requests cannot contain a body"
1654 if (!request
.body.empty() && request.body_artifact) {
1657 "request cannot contain both inline and artifact body bytes"
1661 != request.resume_artifact.has_value()) {
1664 "resume_download requires exactly one resume_artifact"
1667 if (request.resume_artifact
1669 || request.resume_artifact->byte_length == 0U
1670 || request.resume_artifact->byte_length >= request
.max_bytes
1671 || request.resume_artifact->storage_ref
1675 "resume artifact, method, target, or size is inconsistent"
1678 if (request
.body.size()
1679 >
static_cast<std::size_t>(std::numeric_limits<curl_off_t>::max())) {
1682 "inline request body is too large for this transport build"
1685 if (!std::ranges::all_of(request.headers, valid_header)) {
1688 "request contains an invalid HTTP header"
1694 "target artifact reference is not a safe relative path"
1698 std::string validation_error;
1699 auto current = parse_url(request.url, validation_error);
1703 std::move(validation_error)
1707 *current, request.redirects.allowed_hosts, validation_error
1711 std::move(validation_error)
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,
1724 std::move(result), body_status, std::move(validation_error)
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,
1737 std::move(result), resume_status, std::move(validation_error)
1742 const std::filesystem::path target = crypto::safe_artifact_path(
1743 artifact_root_, request.target_artifact_ref
1745 if (!ensure_safe_parent(artifact_root_, target, validation_error)) {
1748 std::move(validation_error)
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) {
1757 "target artifact already exists"
1760 if (ec && ec != std::errc::no_such_file_or_directory) {
1763 "cannot inspect target artifact: " + ec.message()
1767 std::optional<staging_file> staging;
1769 staging.emplace(target.parent_path());
1770 }
catch (
const std::exception& exception) {
1780 "curl_easy_init failed"
1786 request_headers = build_request_headers(request.headers);
1787 }
catch (
const std::exception& exception) {
1796 .read_timeout = request.read_timeout,
1797 .write_timeout = request.write_timeout,
1799 && (!request
.body.empty()
1800 || (body_file && body_file->length() != 0U)),
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);
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);
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);
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()
1824 curl_easy_setopt(handle.get(), CURLOPT_WRITEFUNCTION,
body_callback);
1825 curl_easy_setopt(handle.get(), CURLOPT_WRITEDATA, &received);
1829 curl_easy_setopt(handle.get(), CURLOPT_HEADERDATA, &received);
1831 handle.get(), CURLOPT_MAXFILESIZE_LARGE,
1832 static_cast<curl_off_t>(std::min<std::uint64_t>(
1834 static_cast<std::uint64_t>(std::numeric_limits<curl_off_t>::max())
1837 if (request_headers) {
1839 handle.get(), CURLOPT_HTTPHEADER, request_headers.get()
1843 curl_easy_setopt(handle.get(), CURLOPT_POST, 1L);
1848 curl_easy_setopt(handle.get(), CURLOPT_READDATA, &*body_file);
1852 curl_easy_setopt(handle.get(), CURLOPT_SEEKDATA, &*body_file);
1854 handle.get(), CURLOPT_POSTFIELDSIZE_LARGE,
1855 static_cast<curl_off_t>(body_file->length())
1859 handle.get(), CURLOPT_POSTFIELDS, request
.body.data()
1862 handle.get(), CURLOPT_POSTFIELDSIZE_LARGE,
1863 static_cast<curl_off_t>(request
.body.size())
1867 curl_easy_setopt(handle.get(), CURLOPT_HTTPGET, 1L);
1871 handle.get(), CURLOPT_RESUME_FROM_LARGE,
1872 static_cast<curl_off_t>(resume_file->length())
1876 const auto deadline = std::chrono::steady_clock::now() + request.timeout;
1878 bool transfer_completed =
false;
1879 std::chrono::milliseconds retry_delay_spent { 0 };
1880 auto prepare_staging = [&]() ->
bool {
1881 if (!staging->reset(validation_error)) {
1888 if (!resume_file->rewind(validation_error)) {
1891 std::array<
char, 64U * 1024U> buffer {};
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();
1905 staging->descriptor(), buffer.data(), count,
1908 if (validation_error.empty()) {
1909 validation_error =
"resume artifact exceeds max_bytes";
1913 received.hasher.update(
1914 std::as_bytes(std::span(buffer.data(), count))
1916 received
.byte_count +=
static_cast<std::uint64_t>(count);
1920 for (std::size_t attempt = 1;
1924 current = initial_url;
1925 result.redirect_chain.clear();
1926 std::size_t redirect_count = 0;
1927 if (!prepare_staging()) {
1930 std::move(validation_error)
1935 const long remaining = timeout_milliseconds(deadline);
1936 if (remaining <= 0) {
1939 "transport deadline expired"
1942 curl_easy_setopt(handle.get(), CURLOPT_TIMEOUT_MS, remaining);
1944 std::string rewind_error;
1945 if (!body_file->rewind(rewind_error)) {
1948 std::move(rewind_error)
1953 handle.get(), CURLOPT_URL, current->normalized_url.c_str()
1956 curl_error.fill(
'\0');
1957 const CURLcode curl_status = curl_easy_perform(handle.get());
1959 char* effective_raw =
nullptr;
1961 handle.get(), CURLINFO_EFFECTIVE_URL, &effective_raw
1964 ? current->normalized_url
1965 : std::string(effective_raw);
1967 handle.get(), CURLINFO_RESPONSE_CODE, &result
.http_status
1969 result.response_headers = received.headers;
1972 if (received
.too_large || curl_status == CURLE_FILESIZE_EXCEEDED) {
1975 "response exceeded max_bytes"
1981 "response headers exceeded the safety limit"
1990 if (body_file && body_file->read_failed()) {
1993 body_file->read_error()
1996 if (curl_status != CURLE_OK) {
1998 = curl_status == CURLE_OPERATION_TIMEDOUT
2003 ?
"transport read-progress timeout expired"
2005 ?
"transport write-progress timeout expired"
2006 : curl_error.front() ==
'\0'
2007 ? curl_easy_strerror(curl_status)
2008 : curl_error.data();
2011 && timeout_milliseconds(deadline) > 0;
2013 const auto delay = retry_delay(request, attempt, {});
2014 if (retry_delay_spent + delay
2015 > request.total_retry_delay_budget) {
2019 "network retry delay budget was exhausted"
2022 if (delay.count() >= timeout_milliseconds(deadline)) {
2025 "transport deadline would expire during retry delay"
2028 retry_delay_spent += delay;
2029 std::this_thread::sleep_for(delay);
2032 return fail_result(std::move(result), status, message);
2037 if (!request.redirects.follow) {
2040 "response attempted a redirect while redirects are "
2044 if (redirect_count >= request.redirects.maximum_redirects) {
2048 "response exceeded the configured redirect limit"
2052 std::string redirect_error;
2053 auto redirected_url = resolve_redirect(
2054 result.effective_url, received.location, redirect_error
2056 auto redirected = redirected_url
2057 ? parse_url(*redirected_url, redirect_error)
2062 std::move(redirect_error)
2065 if (current->scheme ==
"https" && redirected->scheme ==
"http"
2066 && !request.redirects.allow_https_to_http) {
2069 "HTTPS-to-HTTP redirect is prohibited"
2073 *redirected, request.redirects.allowed_hosts,
2078 std::move(redirect_error)
2081 result.redirect_chain.emplace_back(redirected->normalized_url);
2082 current = std::move(redirected);
2084 if (!prepare_staging()) {
2087 std::move(validation_error)
2095 "provider did not honor the resumable byte-range request"
2098 result.retry_after = retry_after_delay(received.headers);
2102 = retry_delay(request, attempt, received.headers);
2103 if (retry_delay_spent + delay
2104 > request.total_retry_delay_budget) {
2108 "HTTP retry delay budget was exhausted"
2111 if (delay.count() >= timeout_milliseconds(deadline)) {
2114 "transport deadline would expire during retry delay"
2117 retry_delay_spent += delay;
2118 std::this_thread::sleep_for(delay);
2121 transfer_completed =
true;
2126 std::string synchronize_error;
2127 if (!staging->synchronize(synchronize_error)) {
2130 std::move(synchronize_error)
2133 const std::string digest = received.hasher.finish_hex();
2134 if (request.expected_sha256 && digest != *request.expected_sha256) {
2137 "delivered bytes do not match expected SHA-256"
2143 std::filesystem::create_hard_link(staging->path(), target, ec);
2149 std::move(result), status,
2150 "cannot atomically publish artifact: " + ec.message()
2154 std::filesystem::remove(staging->path(), ec);
2164 result.completed_at = std::chrono::system_clock::now();